dstar_gateway_core/codec/dextra/
decode.rs

1//! `DExtra` packet decoders.
2//!
3//! Both directions — `decode_client_to_server` parses packets a
4//! client would send, `decode_server_to_client` parses packets a
5//! reflector would send.
6//!
7//! Lenient: recoverable malformations push `Diagnostic`s to the
8//! supplied sink but still return a parsed packet. Only fatal errors
9//! (wrong length, missing magic, zero stream id, invalid module byte)
10//! return `Err`.
11
12use crate::header::{DStarHeader, ENCODED_LEN};
13use crate::types::{Callsign, Module, ProtocolKind, StreamId};
14use crate::validator::{Diagnostic, DiagnosticSink};
15use crate::voice::VoiceFrame;
16
17use super::consts::{
18    CONNECT_ACK_TAG, CONNECT_LEN, CONNECT_NAK_TAG, CONNECT_REPLY_LEN, DSVT_MAGIC, POLL_LEN,
19    VOICE_DATA_LEN, VOICE_HEADER_LEN,
20};
21use super::error::DExtraError;
22use super::packet::{ClientPacket, ServerPacket};
23
24/// Decode a UDP datagram sent from a `DExtra` client (client → server).
25///
26/// # Errors
27///
28/// - [`DExtraError::UnknownPacketLength`] for unrecognized lengths
29/// - [`DExtraError::DsvtMagicMissing`] for voice-length packets without DSVT magic
30/// - [`DExtraError::StreamIdZero`] for voice packets with stream id 0
31/// - [`DExtraError::InvalidModuleByte`] for LINK/UNLINK with a non-A-Z
32///   module byte at `[8]` or `[9]` (UNLINK still requires byte `[9]` =
33///   `b' '` which is handled separately).
34///
35/// # See also
36///
37/// `ircDDBGateway/Common/DExtraProtocolHandler.cpp` — the reference
38/// parser this decoder mirrors. `xlxd/src/cdextraprotocol.cpp` is
39/// a mirror reference.
40pub fn decode_client_to_server(
41    bytes: &[u8],
42    sink: &mut dyn DiagnosticSink,
43) -> Result<ClientPacket, DExtraError> {
44    let len = bytes.len();
45    match len {
46        POLL_LEN => {
47            let cs = extract_callsign(bytes);
48            Ok(ClientPacket::Poll { callsign: cs })
49        }
50        CONNECT_LEN => decode_client_connect(bytes),
51        VOICE_DATA_LEN => decode_dsvt_client_voice(bytes, sink),
52        VOICE_HEADER_LEN => decode_dsvt_client_header(bytes, sink),
53        _ => Err(DExtraError::UnknownPacketLength { got: len }),
54    }
55}
56
57/// Decode a UDP datagram sent from a `DExtra` reflector (server → client).
58///
59/// # Errors
60///
61/// Same as [`decode_client_to_server`], but produces [`ServerPacket`]
62/// variants. The 14-byte ACK/NAK reply length is only accepted here.
63///
64/// # See also
65///
66/// `ircDDBGateway/Common/DExtraProtocolHandler.cpp` — the reference
67/// parser for the server-side `DExtra` wire format.
68pub fn decode_server_to_client(
69    bytes: &[u8],
70    sink: &mut dyn DiagnosticSink,
71) -> Result<ServerPacket, DExtraError> {
72    let len = bytes.len();
73    match len {
74        POLL_LEN => {
75            let cs = extract_callsign(bytes);
76            Ok(ServerPacket::PollEcho { callsign: cs })
77        }
78        CONNECT_REPLY_LEN => decode_server_connect_reply(bytes),
79        VOICE_DATA_LEN => decode_dsvt_server_voice(bytes, sink),
80        VOICE_HEADER_LEN => decode_dsvt_server_header(bytes, sink),
81        _ => Err(DExtraError::UnknownPacketLength { got: len }),
82    }
83}
84
85/// Extract an 8-byte callsign from a packet prefix (positions `[0..8]`).
86///
87/// Callers must have already verified that the slice is at least 8
88/// bytes long. Any zeros in the callsign field are normalized to
89/// spaces so that `Callsign::from_wire_bytes` records a clean wire
90/// representation.
91///
92/// On the connect-packet wire format, byte `[7]` is the pad slot
93/// (space from `memset`) and byte `[8]` — outside the 8-byte
94/// window — holds the module letter per
95/// `ircDDBGateway/Common/ConnectData.cpp:278-300` (`getDExtraData`).
96/// Our API exposes the module as a separate `Module` field on
97/// [`ClientPacket::Link`]/[`ServerPacket::ConnectAck`], so this
98/// reader deliberately does NOT splice byte `[8]` into the
99/// callsign. Keeping byte `[7]` as the plain space from the wire
100/// matches what the 9-byte poll packet decoder sees and lets the
101/// server session compare stored callsigns against incoming polls
102/// byte-for-byte.
103fn extract_callsign(bytes: &[u8]) -> Callsign {
104    let mut cs = [b' '; 8];
105    if let Some(src) = bytes.get(..8) {
106        cs.copy_from_slice(src);
107    }
108    for b in &mut cs {
109        if *b == 0 {
110            *b = b' ';
111        }
112    }
113    Callsign::from_wire_bytes(cs)
114}
115
116/// Decode an 11-byte LINK or UNLINK client packet.
117fn decode_client_connect(bytes: &[u8]) -> Result<ClientPacket, DExtraError> {
118    let cs = extract_callsign(bytes);
119    let client_byte = bytes.get(8).copied().unwrap_or(0);
120    let client_module =
121        Module::try_from_byte(client_byte).map_err(|_| DExtraError::InvalidModuleByte {
122            offset: 8,
123            byte: client_byte,
124        })?;
125    let reflector_byte = bytes.get(9).copied().unwrap_or(0);
126    if reflector_byte == b' ' {
127        Ok(ClientPacket::Unlink {
128            callsign: cs,
129            client_module,
130        })
131    } else {
132        let reflector_module =
133            Module::try_from_byte(reflector_byte).map_err(|_| DExtraError::InvalidModuleByte {
134                offset: 9,
135                byte: reflector_byte,
136            })?;
137        Ok(ClientPacket::Link {
138            callsign: cs,
139            reflector_module,
140            client_module,
141        })
142    }
143}
144
145/// Decode a 14-byte ACK or NAK reply from the server.
146fn decode_server_connect_reply(bytes: &[u8]) -> Result<ServerPacket, DExtraError> {
147    let cs = extract_callsign(bytes);
148    // Position [9] carries the reflector module letter.
149    let module_byte = bytes.get(9).copied().unwrap_or(0);
150    let reflector_module =
151        Module::try_from_byte(module_byte).map_err(|_| DExtraError::InvalidModuleByte {
152            offset: 9,
153            byte: module_byte,
154        })?;
155    // Tag is at [10..13], NUL at [13] — per
156    // `ircDDBGateway/Common/ConnectData.cpp:302-316`.
157    let mut tag = [0u8; 3];
158    if let Some(src) = bytes.get(10..13) {
159        tag.copy_from_slice(src);
160    }
161    if tag == CONNECT_ACK_TAG {
162        Ok(ServerPacket::ConnectAck {
163            callsign: cs,
164            reflector_module,
165        })
166    } else if tag == CONNECT_NAK_TAG {
167        Ok(ServerPacket::ConnectNak {
168            callsign: cs,
169            reflector_module,
170        })
171    } else {
172        Err(DExtraError::UnknownConnectTag { tag })
173    }
174}
175
176/// Decode a 27-byte voice data/EOT packet from the client.
177fn decode_dsvt_client_voice(
178    bytes: &[u8],
179    _sink: &mut dyn DiagnosticSink,
180) -> Result<ClientPacket, DExtraError> {
181    let (stream_id, seq, frame) = parse_dsvt_voice(bytes)?;
182    if seq & 0x40 != 0 {
183        Ok(ClientPacket::VoiceEot { stream_id, seq })
184    } else {
185        Ok(ClientPacket::VoiceData {
186            stream_id,
187            seq,
188            frame,
189        })
190    }
191}
192
193/// Decode a 27-byte voice data/EOT packet from the server.
194fn decode_dsvt_server_voice(
195    bytes: &[u8],
196    _sink: &mut dyn DiagnosticSink,
197) -> Result<ServerPacket, DExtraError> {
198    let (stream_id, seq, frame) = parse_dsvt_voice(bytes)?;
199    if seq & 0x40 != 0 {
200        Ok(ServerPacket::VoiceEot { stream_id, seq })
201    } else {
202        Ok(ServerPacket::VoiceData {
203            stream_id,
204            seq,
205            frame,
206        })
207    }
208}
209
210/// Decode a 56-byte voice header packet from the client.
211fn decode_dsvt_client_header(
212    bytes: &[u8],
213    sink: &mut dyn DiagnosticSink,
214) -> Result<ClientPacket, DExtraError> {
215    let (stream_id, header) = parse_dsvt_header(bytes, sink)?;
216    Ok(ClientPacket::VoiceHeader { stream_id, header })
217}
218
219/// Decode a 56-byte voice header packet from the server.
220fn decode_dsvt_server_header(
221    bytes: &[u8],
222    sink: &mut dyn DiagnosticSink,
223) -> Result<ServerPacket, DExtraError> {
224    let (stream_id, header) = parse_dsvt_header(bytes, sink)?;
225    Ok(ServerPacket::VoiceHeader { stream_id, header })
226}
227
228/// Shared parser for the 27-byte voice data / EOT packet shape.
229fn parse_dsvt_voice(bytes: &[u8]) -> Result<(StreamId, u8, VoiceFrame), DExtraError> {
230    check_dsvt_magic(bytes)?;
231    // Byte [4] must be 0x20 (voice type) for data/EOT.
232    if bytes.get(4).copied() != Some(0x20) {
233        return Err(DExtraError::UnknownPacketLength { got: bytes.len() });
234    }
235    let stream_id = extract_stream_id(bytes)?;
236    let seq = bytes.get(14).copied().unwrap_or(0);
237    let mut ambe = [0u8; 9];
238    let mut slow = [0u8; 3];
239    if let Some(src) = bytes.get(15..24) {
240        ambe.copy_from_slice(src);
241    }
242    if let Some(src) = bytes.get(24..27) {
243        slow.copy_from_slice(src);
244    }
245    Ok((
246        stream_id,
247        seq,
248        VoiceFrame {
249            ambe,
250            slow_data: slow,
251        },
252    ))
253}
254
255/// Shared parser for the 56-byte voice header packet shape.
256fn parse_dsvt_header(
257    bytes: &[u8],
258    sink: &mut dyn DiagnosticSink,
259) -> Result<(StreamId, DStarHeader), DExtraError> {
260    check_dsvt_magic(bytes)?;
261    // Byte [4] must be 0x10 (header type) for a voice header.
262    if bytes.get(4).copied() != Some(0x10) {
263        return Err(DExtraError::UnknownPacketLength { got: bytes.len() });
264    }
265    let stream_id = extract_stream_id(bytes)?;
266    let hdr_slice = bytes
267        .get(15..56)
268        .ok_or(DExtraError::UnknownPacketLength { got: bytes.len() })?;
269    let mut arr = [0u8; ENCODED_LEN];
270    arr.copy_from_slice(hdr_slice);
271    let decoded = DStarHeader::decode(&arr);
272    if decoded.flag1 != 0 || decoded.flag2 != 0 || decoded.flag3 != 0 {
273        sink.record(Diagnostic::HeaderFlagsNonZero {
274            protocol: ProtocolKind::DExtra,
275            flag1: decoded.flag1,
276            flag2: decoded.flag2,
277            flag3: decoded.flag3,
278        });
279    }
280    Ok((stream_id, decoded))
281}
282
283/// Verify DSVT magic at offset `[0..4]`.
284fn check_dsvt_magic(bytes: &[u8]) -> Result<(), DExtraError> {
285    let slice = bytes
286        .get(..4)
287        .ok_or(DExtraError::UnknownPacketLength { got: bytes.len() })?;
288    if slice == DSVT_MAGIC.as_slice() {
289        Ok(())
290    } else {
291        let mut got = [0u8; 4];
292        got.copy_from_slice(slice);
293        Err(DExtraError::DsvtMagicMissing { got })
294    }
295}
296
297/// Extract the non-zero stream id from offsets `[12..14]` (little-endian).
298fn extract_stream_id(bytes: &[u8]) -> Result<StreamId, DExtraError> {
299    let lo = bytes.get(12).copied().unwrap_or(0);
300    let hi = bytes.get(13).copied().unwrap_or(0);
301    let raw = u16::from_le_bytes([lo, hi]);
302    StreamId::new(raw).ok_or(DExtraError::StreamIdZero)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::codec::dextra::encode::{
309        encode_connect_ack, encode_connect_link, encode_connect_nak, encode_poll, encode_unlink,
310        encode_voice_data, encode_voice_eot, encode_voice_header,
311    };
312    use crate::types::Suffix;
313    use crate::validator::NullSink;
314
315    type TestResult = Result<(), Box<dyn std::error::Error>>;
316
317    const fn cs(bytes: [u8; 8]) -> Callsign {
318        Callsign::from_wire_bytes(bytes)
319    }
320
321    #[expect(clippy::unwrap_used, reason = "compile-time validated: n != 0")]
322    const fn sid(n: u16) -> StreamId {
323        StreamId::new(n).unwrap()
324    }
325
326    fn test_header() -> DStarHeader {
327        DStarHeader {
328            flag1: 0,
329            flag2: 0,
330            flag3: 0,
331            rpt2: cs(*b"XRF030 G"),
332            rpt1: cs(*b"XRF030 C"),
333            ur_call: cs(*b"CQCQCQ  "),
334            my_call: cs(*b"W1AW    "),
335            my_suffix: Suffix::EMPTY,
336        }
337    }
338
339    // ─── Client-side roundtrips ────────────────────────────────
340    #[test]
341    fn link_client_roundtrip() -> TestResult {
342        // Encoder writes the callsign's first 7 bytes at [0..7],
343        // leaves [7] as the memset space pad, and places the
344        // client_module at [8]. The decoder reads [0..8] verbatim
345        // (space at byte 7) and extracts the module from [8]
346        // separately, so the round-trip is exact.
347        let mut buf = [0u8; 16];
348        let n = encode_connect_link(&mut buf, &cs(*b"W1AW    "), Module::C, Module::B)?;
349        let pkt = decode_client_to_server(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
350        match pkt {
351            ClientPacket::Link {
352                callsign,
353                reflector_module,
354                client_module,
355            } => {
356                assert_eq!(callsign, cs(*b"W1AW    "));
357                assert_eq!(reflector_module, Module::C);
358                assert_eq!(client_module, Module::B);
359            }
360            other => return Err(format!("expected Link, got {other:?}").into()),
361        }
362        Ok(())
363    }
364
365    #[test]
366    fn unlink_client_roundtrip() -> TestResult {
367        let mut buf = [0u8; 16];
368        let n = encode_unlink(&mut buf, &cs(*b"W1AW    "), Module::B)?;
369        let pkt = decode_client_to_server(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
370        match pkt {
371            ClientPacket::Unlink {
372                callsign,
373                client_module,
374            } => {
375                assert_eq!(callsign, cs(*b"W1AW    "));
376                assert_eq!(client_module, Module::B);
377            }
378            other => return Err(format!("expected Unlink, got {other:?}").into()),
379        }
380        Ok(())
381    }
382
383    #[test]
384    fn poll_client_roundtrip() -> TestResult {
385        let mut buf = [0u8; 16];
386        let n = encode_poll(&mut buf, &cs(*b"W1AW    "))?;
387        let pkt = decode_client_to_server(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
388        match pkt {
389            ClientPacket::Poll { callsign } => {
390                assert_eq!(callsign, cs(*b"W1AW    "));
391            }
392            other => return Err(format!("expected Poll, got {other:?}").into()),
393        }
394        Ok(())
395    }
396
397    #[test]
398    fn voice_header_client_roundtrip() -> TestResult {
399        let mut buf = [0u8; 64];
400        let n = encode_voice_header(&mut buf, sid(0xCAFE), &test_header())?;
401        let pkt = decode_client_to_server(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
402        match pkt {
403            ClientPacket::VoiceHeader { stream_id, header } => {
404                assert_eq!(stream_id, sid(0xCAFE));
405                assert_eq!(header.my_call, test_header().my_call);
406            }
407            other => return Err(format!("expected VoiceHeader, got {other:?}").into()),
408        }
409        Ok(())
410    }
411
412    #[test]
413    fn voice_data_client_roundtrip() -> TestResult {
414        let frame = VoiceFrame {
415            ambe: [0x11; 9],
416            slow_data: [0x22; 3],
417        };
418        let mut buf = [0u8; 64];
419        let n = encode_voice_data(&mut buf, sid(0x1234), 5, &frame)?;
420        let pkt = decode_client_to_server(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
421        match pkt {
422            ClientPacket::VoiceData {
423                stream_id,
424                seq,
425                frame: f,
426            } => {
427                assert_eq!(stream_id, sid(0x1234));
428                assert_eq!(seq, 5);
429                assert_eq!(f.ambe, [0x11; 9]);
430                assert_eq!(f.slow_data, [0x22; 3]);
431            }
432            other => return Err(format!("expected VoiceData, got {other:?}").into()),
433        }
434        Ok(())
435    }
436
437    #[test]
438    fn voice_eot_client_roundtrip() -> TestResult {
439        let mut buf = [0u8; 64];
440        let n = encode_voice_eot(&mut buf, sid(0x1234), 7)?;
441        let pkt = decode_client_to_server(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
442        match pkt {
443            ClientPacket::VoiceEot { stream_id, seq } => {
444                assert_eq!(stream_id, sid(0x1234));
445                assert_eq!(seq & 0x40, 0x40, "EOT bit set");
446                assert_eq!(seq & 0x3F, 7, "low bits preserve seq");
447            }
448            other => return Err(format!("expected VoiceEot, got {other:?}").into()),
449        }
450        Ok(())
451    }
452
453    // ─── Server-side roundtrips ────────────────────────────────
454    #[test]
455    fn connect_ack_server_roundtrip() -> TestResult {
456        let mut buf = [0u8; 16];
457        let n = encode_connect_ack(&mut buf, &cs(*b"XRF030  "), Module::C)?;
458        let pkt = decode_server_to_client(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
459        match pkt {
460            ServerPacket::ConnectAck {
461                callsign,
462                reflector_module,
463            } => {
464                assert_eq!(callsign, cs(*b"XRF030  "));
465                assert_eq!(reflector_module, Module::C);
466            }
467            other => return Err(format!("expected ConnectAck, got {other:?}").into()),
468        }
469        Ok(())
470    }
471
472    #[test]
473    fn connect_nak_server_roundtrip() -> TestResult {
474        let mut buf = [0u8; 16];
475        let n = encode_connect_nak(&mut buf, &cs(*b"XRF030  "), Module::C)?;
476        let pkt = decode_server_to_client(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
477        match pkt {
478            ServerPacket::ConnectNak {
479                callsign,
480                reflector_module,
481            } => {
482                assert_eq!(callsign, cs(*b"XRF030  "));
483                assert_eq!(reflector_module, Module::C);
484            }
485            other => return Err(format!("expected ConnectNak, got {other:?}").into()),
486        }
487        Ok(())
488    }
489
490    #[test]
491    fn poll_echo_server_roundtrip() -> TestResult {
492        let mut buf = [0u8; 16];
493        let n = encode_poll(&mut buf, &cs(*b"XRF030  "))?;
494        let pkt = decode_server_to_client(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
495        match pkt {
496            ServerPacket::PollEcho { callsign } => {
497                assert_eq!(callsign, cs(*b"XRF030  "));
498            }
499            other => return Err(format!("expected PollEcho, got {other:?}").into()),
500        }
501        Ok(())
502    }
503
504    #[test]
505    fn voice_header_server_roundtrip() -> TestResult {
506        let mut buf = [0u8; 64];
507        let n = encode_voice_header(&mut buf, sid(0xCAFE), &test_header())?;
508        let pkt = decode_server_to_client(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
509        match pkt {
510            ServerPacket::VoiceHeader { stream_id, header } => {
511                assert_eq!(stream_id, sid(0xCAFE));
512                assert_eq!(header.my_call, test_header().my_call);
513            }
514            other => return Err(format!("expected VoiceHeader, got {other:?}").into()),
515        }
516        Ok(())
517    }
518
519    #[test]
520    fn voice_data_server_roundtrip() -> TestResult {
521        let frame = VoiceFrame {
522            ambe: [0x33; 9],
523            slow_data: [0x44; 3],
524        };
525        let mut buf = [0u8; 64];
526        let n = encode_voice_data(&mut buf, sid(0x4321), 9, &frame)?;
527        let pkt = decode_server_to_client(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
528        match pkt {
529            ServerPacket::VoiceData {
530                stream_id,
531                seq,
532                frame: f,
533            } => {
534                assert_eq!(stream_id, sid(0x4321));
535                assert_eq!(seq, 9);
536                assert_eq!(f.ambe, [0x33; 9]);
537                assert_eq!(f.slow_data, [0x44; 3]);
538            }
539            other => return Err(format!("expected VoiceData, got {other:?}").into()),
540        }
541        Ok(())
542    }
543
544    #[test]
545    fn voice_eot_server_roundtrip() -> TestResult {
546        let mut buf = [0u8; 64];
547        let n = encode_voice_eot(&mut buf, sid(0x1234), 7)?;
548        let pkt = decode_server_to_client(buf.get(..n).ok_or("n within buf")?, &mut NullSink)?;
549        match pkt {
550            ServerPacket::VoiceEot { stream_id, seq } => {
551                assert_eq!(stream_id, sid(0x1234));
552                assert_eq!(seq & 0x40, 0x40, "EOT bit set");
553                assert_eq!(seq & 0x3F, 7, "low bits preserve seq");
554            }
555            other => return Err(format!("expected VoiceEot, got {other:?}").into()),
556        }
557        Ok(())
558    }
559
560    // ─── Error cases ───────────────────────────────────────────
561    #[test]
562    fn unknown_length_returns_error() -> TestResult {
563        let Err(err) = decode_client_to_server(&[0u8; 12], &mut NullSink) else {
564            return Err("expected error for bad length".into());
565        };
566        assert!(matches!(err, DExtraError::UnknownPacketLength { got: 12 }));
567        Ok(())
568    }
569
570    #[test]
571    fn server_rejects_11_byte_client_link() -> TestResult {
572        // 11-byte LINK is client-only; the server sends 14-byte replies.
573        let Err(err) = decode_server_to_client(&[0u8; 11], &mut NullSink) else {
574            return Err("expected error for server rejecting 11-byte".into());
575        };
576        assert!(matches!(err, DExtraError::UnknownPacketLength { got: 11 }));
577        Ok(())
578    }
579
580    #[test]
581    fn client_rejects_14_byte_server_reply() -> TestResult {
582        // 14-byte ACK/NAK is server-only.
583        let Err(err) = decode_client_to_server(&[0u8; 14], &mut NullSink) else {
584            return Err("expected error for client rejecting 14-byte".into());
585        };
586        assert!(matches!(err, DExtraError::UnknownPacketLength { got: 14 }));
587        Ok(())
588    }
589
590    #[test]
591    fn link_with_invalid_client_module_byte() -> TestResult {
592        // Valid callsign, invalid (lowercase) client module byte.
593        let mut bytes = [b' '; 11];
594        bytes[..4].copy_from_slice(b"W1AW");
595        bytes[8] = b'b'; // lowercase — invalid
596        bytes[9] = b'C';
597        bytes[10] = 0x00;
598        let Err(err) = decode_client_to_server(&bytes, &mut NullSink) else {
599            return Err("expected error for invalid module byte".into());
600        };
601        assert!(matches!(
602            err,
603            DExtraError::InvalidModuleByte {
604                offset: 8,
605                byte: b'b'
606            }
607        ));
608        Ok(())
609    }
610
611    #[test]
612    fn link_with_invalid_reflector_module_byte() -> TestResult {
613        let mut bytes = [b' '; 11];
614        bytes[..4].copy_from_slice(b"W1AW");
615        bytes[8] = b'B';
616        bytes[9] = b'1'; // digit — invalid
617        bytes[10] = 0x00;
618        let Err(err) = decode_client_to_server(&bytes, &mut NullSink) else {
619            return Err("expected error for invalid reflector module byte".into());
620        };
621        assert!(matches!(
622            err,
623            DExtraError::InvalidModuleByte {
624                offset: 9,
625                byte: b'1'
626            }
627        ));
628        Ok(())
629    }
630
631    #[test]
632    fn voice_data_with_zero_stream_id_rejected() -> TestResult {
633        let mut bytes = [0u8; 27];
634        bytes[..4].copy_from_slice(b"DSVT");
635        bytes[4] = 0x20;
636        bytes[8] = 0x20;
637        // stream_id at [12..14] left as zero.
638        let Err(err) = decode_client_to_server(&bytes, &mut NullSink) else {
639            return Err("expected error for zero stream id".into());
640        };
641        assert!(matches!(err, DExtraError::StreamIdZero));
642        Ok(())
643    }
644
645    #[test]
646    fn voice_header_missing_dsvt_magic_rejected() -> TestResult {
647        let mut bytes = [0u8; 56];
648        bytes[..4].copy_from_slice(b"XXXX"); // wrong magic
649        bytes[4] = 0x10;
650        bytes[12] = 0x34;
651        bytes[13] = 0x12;
652        let Err(err) = decode_client_to_server(&bytes, &mut NullSink) else {
653            return Err("expected error for bad DSVT magic".into());
654        };
655        assert!(matches!(err, DExtraError::DsvtMagicMissing { .. }));
656        Ok(())
657    }
658
659    #[test]
660    fn connect_reply_with_unknown_tag() -> TestResult {
661        let mut bytes = [b' '; 14];
662        bytes[..4].copy_from_slice(b"XRF0");
663        bytes[8] = b'C';
664        bytes[9] = b'C';
665        // Tag is at [10..13] per the reference; byte 13 is NUL.
666        bytes[10..13].copy_from_slice(b"FOO");
667        bytes[13] = 0x00;
668        let Err(err) = decode_server_to_client(&bytes, &mut NullSink) else {
669            return Err("expected error for unknown connect tag".into());
670        };
671        assert!(matches!(err, DExtraError::UnknownConnectTag { .. }));
672        Ok(())
673    }
674}