Skip to main content

oxideav_rtmp/
caps.rs

1//! Enhanced RTMP NetConnection `connect` capability negotiation.
2//!
3//! When a client opens a NetConnection it sends the `connect` command
4//! whose Command Object carries a small bag of name/value pairs that
5//! declare its protocol level. Enhanced RTMP v1 (Veovera, 2023) added
6//! the `fourCcList` strict-array of supported FourCC codecs. Enhanced
7//! RTMP v2 (Veovera, 2026, `enhanced-rtmp-v2.pdf` §"Enhancing
8//! NetConnection connect Command") added two further entries:
9//!
10//! * `videoFourCcInfoMap` / `audioFourCcInfoMap` — per-codec object
11//!   maps whose values are bitmask numbers built from `FourCcInfoMask`
12//!   (`CanDecode` / `CanEncode` / `CanForward`). A FourCC key of `"*"`
13//!   acts as a catch-all for any codec.
14//! * `capsEx` — a single u32 bitfield built from `CapsExMask` declaring
15//!   extended capabilities: `Reconnect`, `Multitrack`, `ModEx`,
16//!   `TimestampNanoOffset`.
17//!
18//! Servers echo their own capabilities back in the `_result` reply's
19//! properties object using the same names so both sides converge on a
20//! common feature subset before any media flows.
21//!
22//! [`ConnectCapabilities`] is the strongly-typed representation of all
23//! four properties plus the legacy `objectEncoding` u8 (0 = AMF0, 3 =
24//! AMF0+AMF3 switch via `avmplus-object-marker`). It encodes/decodes
25//! against an [`Amf0Value`] graph via [`ConnectCapabilities::encode_into`]
26//! / [`ConnectCapabilities::from_amf0`] without disturbing the surrounding
27//! command-object key order — additions append after the legacy
28//! `audioCodecs` / `videoCodecs` / `videoFunction` block, so a pre-2023
29//! receiver still parses everything it understands.
30
31use crate::amf::Amf0Value;
32
33// ---------------------------------------------------------------------------
34// FourCcInfoMask — per-codec capability bits (v2 §"Enhancing connect")
35// ---------------------------------------------------------------------------
36
37/// `FourCcInfoMask.CanDecode` — the endpoint can decode this codec.
38pub const FOURCC_INFO_CAN_DECODE: u32 = 0x01;
39/// `FourCcInfoMask.CanEncode` — the endpoint can encode this codec.
40pub const FOURCC_INFO_CAN_ENCODE: u32 = 0x02;
41/// `FourCcInfoMask.CanForward` — the endpoint can forward the codec
42/// without decoding (relay / recorder / forwarding ingest).
43pub const FOURCC_INFO_CAN_FORWARD: u32 = 0x04;
44
45// ---------------------------------------------------------------------------
46// CapsExMask — extended-capability bitfield (v2 §"Enhancing connect")
47// ---------------------------------------------------------------------------
48
49/// `CapsExMask.Reconnect` — the endpoint honours the
50/// `NetConnection.Connect.ReconnectRequest` onStatus event.
51pub const CAPS_EX_RECONNECT: u32 = 0x01;
52/// `CapsExMask.Multitrack` — the endpoint understands the v2 Multitrack
53/// audio + video PacketTypes (per-track FourCC + size-prefixed track
54/// chunks).
55pub const CAPS_EX_MULTITRACK: u32 = 0x02;
56/// `CapsExMask.ModEx` — the endpoint can parse the v2 ModEx
57/// packet-type prelude (size-prefixed extension chain ahead of the
58/// real packet type).
59pub const CAPS_EX_MOD_EX: u32 = 0x04;
60/// `CapsExMask.TimestampNanoOffset` — the endpoint applies the
61/// ModEx `TimestampOffsetNano = 0` sub-millisecond presentation offset
62/// to its decode pipeline.
63pub const CAPS_EX_TIMESTAMP_NANO_OFFSET: u32 = 0x08;
64
65/// `objectEncoding` value 0 — AMF0-only.
66pub const OBJECT_ENCODING_AMF0: u8 = 0;
67/// `objectEncoding` value 3 — AMF0 with `avmplus-object-marker`
68/// switching to AMF3 per the AMF0 spec.
69pub const OBJECT_ENCODING_AMF3: u8 = 3;
70
71/// FourCC wildcard string — a key of `"*"` in a FourCcInfoMap means
72/// "applies to every codec in the relevant audio/video bucket".
73pub const FOURCC_WILDCARD: &str = "*";
74
75// ---------------------------------------------------------------------------
76// FourCcInfoMap — `videoFourCcInfoMap` / `audioFourCcInfoMap`
77// ---------------------------------------------------------------------------
78
79/// `videoFourCcInfoMap` / `audioFourCcInfoMap` payload — an ordered
80/// list of `(fourCC-or-wildcard, mask)` pairs.
81///
82/// The spec stores this as an AMF0 Object whose property names are
83/// FourCC ASCII (e.g. `"hvc1"`, `"Opus"`) or the catch-all `"*"`, and
84/// whose values are numbers built from [`FOURCC_INFO_CAN_DECODE`] etc.
85/// We keep the entries in insertion order — most peers walk the
86/// properties top-down looking for a wildcard first, then per-codec
87/// overrides, so order is informally load-bearing even though the spec
88/// doesn't mandate it.
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
90pub struct FourCcInfoMap {
91    entries: Vec<(String, u32)>,
92}
93
94impl FourCcInfoMap {
95    /// Empty map — declares no per-codec capabilities.
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Append an entry with the given FourCC string and mask. If `key`
101    /// already exists the new mask replaces the existing value while
102    /// preserving insertion position.
103    pub fn insert<S: Into<String>>(&mut self, key: S, mask: u32) -> &mut Self {
104        let key = key.into();
105        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == &key) {
106            slot.1 = mask;
107        } else {
108            self.entries.push((key, mask));
109        }
110        self
111    }
112
113    /// Convenience: insert a `[u8; 4]` FourCC verbatim. The bytes are
114    /// taken to be ASCII (e.g. `*b"hvc1"`); a non-ASCII byte falls back
115    /// to the lossy UTF-8 conversion, which keeps round-tripping clean
116    /// against a forwarding peer.
117    pub fn insert_fourcc(&mut self, fourcc: [u8; 4], mask: u32) -> &mut Self {
118        let s = String::from_utf8_lossy(&fourcc).into_owned();
119        self.insert(s, mask)
120    }
121
122    /// Look up a mask for the given key. Returns `None` if the key
123    /// isn't present — callers that want wildcard fallback should also
124    /// check [`Self::wildcard`].
125    pub fn get(&self, key: &str) -> Option<u32> {
126        self.entries.iter().find(|(k, _)| k == key).map(|(_, m)| *m)
127    }
128
129    /// Mask carried by the catch-all `"*"` key, if any.
130    pub fn wildcard(&self) -> Option<u32> {
131        self.get(FOURCC_WILDCARD)
132    }
133
134    /// Effective mask for `key`, applying the v2 spec rule that a
135    /// wildcard entry overrides per-codec entries for any flag it sets.
136    pub fn effective_mask(&self, key: &str) -> u32 {
137        let direct = self.get(key).unwrap_or(0);
138        direct | self.wildcard().unwrap_or(0)
139    }
140
141    /// Number of entries.
142    pub fn len(&self) -> usize {
143        self.entries.len()
144    }
145
146    /// True if the map carries no entries.
147    pub fn is_empty(&self) -> bool {
148        self.entries.is_empty()
149    }
150
151    /// Iterate `(key, mask)` pairs in insertion order.
152    pub fn iter(&self) -> impl Iterator<Item = (&str, u32)> {
153        self.entries.iter().map(|(k, m)| (k.as_str(), *m))
154    }
155
156    /// Encode as the AMF0 Object the spec expects.
157    pub fn to_amf0(&self) -> Amf0Value {
158        Amf0Value::Object(
159            self.entries
160                .iter()
161                .map(|(k, m)| (k.clone(), Amf0Value::Number(*m as f64)))
162                .collect(),
163        )
164    }
165
166    /// Lift from an AMF0 Object (or ECMA-array, which some peers emit).
167    /// Non-numeric / non-finite / negative-valued entries are silently
168    /// skipped — the spec demands numeric mask bits and a forged
169    /// `String` or `Date` payload here would otherwise pull the rest of
170    /// the connect command into a hard error. Out-of-u32 numbers
171    /// saturate to `u32::MAX`.
172    pub fn from_amf0(v: &Amf0Value) -> Self {
173        let pairs = match v {
174            Amf0Value::Object(p) | Amf0Value::EcmaArray(p) => p,
175            _ => return Self::new(),
176        };
177        let mut out = Self::new();
178        for (k, val) in pairs {
179            if let Amf0Value::Number(n) = val {
180                if n.is_finite() && *n >= 0.0 {
181                    let m = if *n >= u32::MAX as f64 {
182                        u32::MAX
183                    } else {
184                        *n as u32
185                    };
186                    out.insert(k.clone(), m);
187                }
188            }
189        }
190        out
191    }
192}
193
194// ---------------------------------------------------------------------------
195// ConnectCapabilities — the full v1+v2 capability block.
196// ---------------------------------------------------------------------------
197
198/// Capability block exchanged in the NetConnection `connect` command.
199///
200/// Owns all four spec entries (`fourCcList`, `videoFourCcInfoMap`,
201/// `audioFourCcInfoMap`, `capsEx`) plus the long-standing
202/// `objectEncoding` byte. Encoded into the existing Command Object by
203/// [`Self::encode_into`] without touching the surrounding key order, and
204/// parsed back with [`Self::from_amf0`] from either the client's
205/// Command Object or the server's `_result` properties object.
206///
207/// A default-constructed instance is empty — `is_empty()` is true and
208/// `encode_into` writes nothing, so a caller composing a legacy AVC /
209/// AAC-only `connect` command keeps the pre-2023 byte layout exactly.
210#[derive(Debug, Clone, Default, PartialEq, Eq)]
211pub struct ConnectCapabilities {
212    /// `fourCcList` — Enhanced RTMP v1 strict-array of supported FourCC
213    /// strings (e.g. `"av01"`, `"hvc1"`). The v2 spec deprecates this on
214    /// the client side in favour of `audio/videoFourCcInfoMap`, but
215    /// servers are encouraged to keep supporting both for older clients.
216    pub fourcc_list: Vec<String>,
217    /// `videoFourCcInfoMap` — v2 per-codec capability bits for video
218    /// codecs.
219    pub video_fourcc_info_map: FourCcInfoMap,
220    /// `audioFourCcInfoMap` — v2 per-codec capability bits for audio
221    /// codecs.
222    pub audio_fourcc_info_map: FourCcInfoMap,
223    /// `capsEx` — v2 bag of extended capability bits.
224    pub caps_ex: u32,
225    /// `objectEncoding` — 0 for AMF0-only, 3 for AMF0+AMF3.
226    pub object_encoding: Option<u8>,
227}
228
229impl ConnectCapabilities {
230    /// Empty capability block — encodes to nothing.
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// True when every field is empty / default.
236    pub fn is_empty(&self) -> bool {
237        self.fourcc_list.is_empty()
238            && self.video_fourcc_info_map.is_empty()
239            && self.audio_fourcc_info_map.is_empty()
240            && self.caps_ex == 0
241            && self.object_encoding.is_none()
242    }
243
244    /// Test for a specific `CapsExMask` flag.
245    pub fn supports_caps_ex(&self, mask: u32) -> bool {
246        self.caps_ex & mask != 0
247    }
248
249    /// True when `fourcc_list` includes either the wildcard `"*"` or
250    /// the literal FourCC `key`.
251    pub fn has_fourcc(&self, key: &str) -> bool {
252        self.fourcc_list
253            .iter()
254            .any(|s| s == key || s == FOURCC_WILDCARD)
255    }
256
257    /// Append our capability properties to a command-object pair list.
258    ///
259    /// Each property is only appended when the corresponding field is
260    /// non-default, so encoding an empty block adds zero bytes. Caller
261    /// keeps any other Command Object properties they want around the
262    /// call site — `pairs` is mutated in place.
263    pub fn encode_into(&self, pairs: &mut Vec<(String, Amf0Value)>) {
264        if let Some(enc) = self.object_encoding {
265            pairs.push(("objectEncoding".into(), Amf0Value::Number(enc as f64)));
266        }
267        if !self.fourcc_list.is_empty() {
268            let arr = Amf0Value::StrictArray(
269                self.fourcc_list
270                    .iter()
271                    .map(|s| Amf0Value::String(s.clone()))
272                    .collect(),
273            );
274            pairs.push(("fourCcList".into(), arr));
275        }
276        if !self.video_fourcc_info_map.is_empty() {
277            pairs.push((
278                "videoFourCcInfoMap".into(),
279                self.video_fourcc_info_map.to_amf0(),
280            ));
281        }
282        if !self.audio_fourcc_info_map.is_empty() {
283            pairs.push((
284                "audioFourCcInfoMap".into(),
285                self.audio_fourcc_info_map.to_amf0(),
286            ));
287        }
288        if self.caps_ex != 0 {
289            pairs.push(("capsEx".into(), Amf0Value::Number(self.caps_ex as f64)));
290        }
291    }
292
293    /// Parse any subset of capability properties out of an Object /
294    /// ECMA-array. Missing properties stay at their default; malformed
295    /// values are silently ignored (the spec's "fail gracefully" rule —
296    /// a forged `capsEx = "abc"` from a stale peer must not abort the
297    /// connect handshake).
298    pub fn from_amf0(v: &Amf0Value) -> Self {
299        let mut out = Self::new();
300        let pairs: &[(String, Amf0Value)] = match v {
301            Amf0Value::Object(p) | Amf0Value::EcmaArray(p) => p.as_slice(),
302            _ => return out,
303        };
304        for (k, val) in pairs {
305            match k.as_str() {
306                "objectEncoding" => {
307                    if let Amf0Value::Number(n) = val {
308                        if n.is_finite() && *n >= 0.0 && *n <= u8::MAX as f64 {
309                            out.object_encoding = Some(*n as u8);
310                        }
311                    }
312                }
313                "fourCcList" => {
314                    if let Amf0Value::StrictArray(items) = val {
315                        out.fourcc_list = items
316                            .iter()
317                            .filter_map(|it| match it {
318                                Amf0Value::String(s) => Some(s.clone()),
319                                _ => None,
320                            })
321                            .collect();
322                    }
323                }
324                "videoFourCcInfoMap" => {
325                    out.video_fourcc_info_map = FourCcInfoMap::from_amf0(val);
326                }
327                "audioFourCcInfoMap" => {
328                    out.audio_fourcc_info_map = FourCcInfoMap::from_amf0(val);
329                }
330                "capsEx" => {
331                    if let Amf0Value::Number(n) = val {
332                        if n.is_finite() && *n >= 0.0 {
333                            out.caps_ex = if *n >= u32::MAX as f64 {
334                                u32::MAX
335                            } else {
336                                *n as u32
337                            };
338                        }
339                    }
340                }
341                _ => { /* not a capability property — ignored */ }
342            }
343        }
344        out
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::amf;
352    use crate::flv::{
353        FOURCC_AAC, FOURCC_AC3, FOURCC_AV1, FOURCC_AVC, FOURCC_EAC3, FOURCC_FLAC, FOURCC_HEVC,
354        FOURCC_MP3, FOURCC_OPUS, FOURCC_VP8, FOURCC_VP9, FOURCC_VVC,
355    };
356
357    fn fourcc_str(b: [u8; 4]) -> String {
358        std::str::from_utf8(&b).unwrap().to_owned()
359    }
360
361    /// `FourCcInfoMask` constants match the spec table verbatim.
362    #[test]
363    fn fourcc_info_mask_constants_match_spec() {
364        // From enhanced-rtmp-v2.pdf §"Enhancing NetConnection connect Command":
365        //   enum FourCcInfoMask { CanDecode = 0x01, CanEncode = 0x02, CanForward = 0x04 }
366        assert_eq!(FOURCC_INFO_CAN_DECODE, 0x01);
367        assert_eq!(FOURCC_INFO_CAN_ENCODE, 0x02);
368        assert_eq!(FOURCC_INFO_CAN_FORWARD, 0x04);
369    }
370
371    /// `CapsExMask` constants match the spec table verbatim.
372    #[test]
373    fn caps_ex_mask_constants_match_spec() {
374        // enum CapsExMask {
375        //   Reconnect = 0x01, Multitrack = 0x02, ModEx = 0x04, TimestampNanoOffset = 0x08
376        // }
377        assert_eq!(CAPS_EX_RECONNECT, 0x01);
378        assert_eq!(CAPS_EX_MULTITRACK, 0x02);
379        assert_eq!(CAPS_EX_MOD_EX, 0x04);
380        assert_eq!(CAPS_EX_TIMESTAMP_NANO_OFFSET, 0x08);
381    }
382
383    /// FourCC wildcard is the single-byte `"*"`.
384    #[test]
385    fn fourcc_wildcard_is_star() {
386        assert_eq!(FOURCC_WILDCARD, "*");
387    }
388
389    /// `FourCcInfoMap::insert` preserves insertion order and replaces
390    /// duplicate keys without moving them.
391    #[test]
392    fn fourcc_info_map_insert_preserves_order() {
393        let mut m = FourCcInfoMap::new();
394        m.insert("hvc1", FOURCC_INFO_CAN_DECODE);
395        m.insert_fourcc(FOURCC_AV1, FOURCC_INFO_CAN_DECODE | FOURCC_INFO_CAN_ENCODE);
396        m.insert("hvc1", FOURCC_INFO_CAN_FORWARD); // replace
397        let keys: Vec<_> = m.iter().map(|(k, _)| k.to_owned()).collect();
398        assert_eq!(keys, vec!["hvc1", "av01"]);
399        assert_eq!(m.get("hvc1"), Some(FOURCC_INFO_CAN_FORWARD));
400    }
401
402    /// `effective_mask` ORs in the wildcard entry per spec.
403    #[test]
404    fn fourcc_info_map_wildcard_overrides_per_codec() {
405        let mut m = FourCcInfoMap::new();
406        m.insert("*", FOURCC_INFO_CAN_FORWARD);
407        m.insert("vp09", FOURCC_INFO_CAN_DECODE | FOURCC_INFO_CAN_ENCODE);
408        // Per-codec entry is preserved, wildcard adds CanForward on top.
409        assert_eq!(
410            m.effective_mask("vp09"),
411            FOURCC_INFO_CAN_DECODE | FOURCC_INFO_CAN_ENCODE | FOURCC_INFO_CAN_FORWARD,
412        );
413        // Unknown codec inherits wildcard alone.
414        assert_eq!(m.effective_mask("xxxx"), FOURCC_INFO_CAN_FORWARD);
415    }
416
417    /// `FourCcInfoMap` round-trips through the AMF0 Object shape.
418    #[test]
419    fn fourcc_info_map_amf0_roundtrip() {
420        let mut m = FourCcInfoMap::new();
421        m.insert("*", FOURCC_INFO_CAN_FORWARD);
422        m.insert_fourcc(FOURCC_VP9, FOURCC_INFO_CAN_DECODE | FOURCC_INFO_CAN_ENCODE);
423        let v = m.to_amf0();
424        let back = FourCcInfoMap::from_amf0(&v);
425        assert_eq!(back, m);
426    }
427
428    /// Malformed mask entries are dropped, not propagated.
429    #[test]
430    fn fourcc_info_map_skips_non_number_values() {
431        let v = Amf0Value::Object(vec![
432            ("hvc1".into(), Amf0Value::Number(7.0)),
433            ("Opus".into(), Amf0Value::String("nope".into())),
434            ("avc1".into(), Amf0Value::Number(f64::NAN)),
435            ("vp08".into(), Amf0Value::Number(-1.0)),
436        ]);
437        let m = FourCcInfoMap::from_amf0(&v);
438        let keys: Vec<_> = m.iter().map(|(k, _)| k.to_owned()).collect();
439        assert_eq!(keys, vec!["hvc1"]);
440        assert_eq!(m.get("hvc1"), Some(7));
441    }
442
443    /// Out-of-u32 numeric mask saturates to `u32::MAX`.
444    #[test]
445    fn fourcc_info_map_saturates_oversize_mask() {
446        let v = Amf0Value::Object(vec![("hvc1".into(), Amf0Value::Number(1e20))]);
447        let m = FourCcInfoMap::from_amf0(&v);
448        assert_eq!(m.get("hvc1"), Some(u32::MAX));
449    }
450
451    /// Default capability block is empty and writes no bytes.
452    #[test]
453    fn default_capabilities_emit_nothing() {
454        let caps = ConnectCapabilities::default();
455        assert!(caps.is_empty());
456        let mut pairs = Vec::new();
457        caps.encode_into(&mut pairs);
458        assert!(pairs.is_empty());
459    }
460
461    /// Encoded properties land in the documented v1+v2 order:
462    /// `objectEncoding`, `fourCcList`, `videoFourCcInfoMap`,
463    /// `audioFourCcInfoMap`, `capsEx`.
464    #[test]
465    fn encode_into_uses_documented_order() {
466        let mut video = FourCcInfoMap::new();
467        video.insert("*", FOURCC_INFO_CAN_FORWARD);
468        let mut audio = FourCcInfoMap::new();
469        audio.insert_fourcc(FOURCC_OPUS, FOURCC_INFO_CAN_DECODE);
470        let caps = ConnectCapabilities {
471            object_encoding: Some(OBJECT_ENCODING_AMF3),
472            fourcc_list: vec![fourcc_str(FOURCC_HEVC), fourcc_str(FOURCC_AV1)],
473            video_fourcc_info_map: video,
474            audio_fourcc_info_map: audio,
475            caps_ex: CAPS_EX_RECONNECT | CAPS_EX_MULTITRACK,
476        };
477
478        let mut pairs = Vec::new();
479        caps.encode_into(&mut pairs);
480        let names: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
481        assert_eq!(
482            names,
483            vec![
484                "objectEncoding",
485                "fourCcList",
486                "videoFourCcInfoMap",
487                "audioFourCcInfoMap",
488                "capsEx",
489            ],
490        );
491    }
492
493    /// Round-trip a fully-populated capability block through encode →
494    /// AMF0 wire → decode and assert every field comes back equal.
495    #[test]
496    fn full_capabilities_amf0_roundtrip() {
497        let mut video = FourCcInfoMap::new();
498        video.insert("*", FOURCC_INFO_CAN_FORWARD);
499        video.insert_fourcc(FOURCC_HEVC, FOURCC_INFO_CAN_DECODE | FOURCC_INFO_CAN_ENCODE);
500        video.insert_fourcc(FOURCC_VP9, FOURCC_INFO_CAN_DECODE);
501        let mut audio = FourCcInfoMap::new();
502        audio.insert("*", FOURCC_INFO_CAN_FORWARD);
503        audio.insert_fourcc(FOURCC_OPUS, FOURCC_INFO_CAN_DECODE | FOURCC_INFO_CAN_ENCODE);
504        let caps = ConnectCapabilities {
505            object_encoding: Some(OBJECT_ENCODING_AMF0),
506            fourcc_list: vec![
507                fourcc_str(FOURCC_AV1),
508                fourcc_str(FOURCC_VP9),
509                fourcc_str(FOURCC_VP8),
510                fourcc_str(FOURCC_HEVC),
511                fourcc_str(FOURCC_AVC),
512                fourcc_str(FOURCC_VVC),
513                fourcc_str(FOURCC_AC3),
514                fourcc_str(FOURCC_EAC3),
515                fourcc_str(FOURCC_OPUS),
516                fourcc_str(FOURCC_MP3),
517                fourcc_str(FOURCC_FLAC),
518                fourcc_str(FOURCC_AAC),
519            ],
520            video_fourcc_info_map: video,
521            audio_fourcc_info_map: audio,
522            caps_ex: CAPS_EX_RECONNECT
523                | CAPS_EX_MULTITRACK
524                | CAPS_EX_MOD_EX
525                | CAPS_EX_TIMESTAMP_NANO_OFFSET,
526        };
527
528        let mut pairs = vec![("app".into(), Amf0Value::String("live".into()))];
529        caps.encode_into(&mut pairs);
530        let obj = Amf0Value::Object(pairs);
531        // Encode-decode the wire bytes so the round-trip walks the same
532        // AMF0 path used by the live `connect` handshake.
533        let mut buf = Vec::new();
534        amf::encode(&mut buf, &obj);
535        let mut pos = 0;
536        let decoded = amf::decode(&buf, &mut pos).unwrap();
537        let back = ConnectCapabilities::from_amf0(&decoded);
538        assert_eq!(back, caps);
539    }
540
541    /// `has_fourcc` recognises both the exact entry and a `"*"`
542    /// wildcard.
543    #[test]
544    fn fourcc_list_wildcard_and_explicit() {
545        let caps = ConnectCapabilities {
546            fourcc_list: vec!["*".into()],
547            ..Default::default()
548        };
549        assert!(caps.has_fourcc("av01"));
550        assert!(caps.has_fourcc("xxxx"));
551
552        let caps = ConnectCapabilities {
553            fourcc_list: vec![fourcc_str(FOURCC_HEVC)],
554            ..Default::default()
555        };
556        assert!(caps.has_fourcc("hvc1"));
557        assert!(!caps.has_fourcc("av01"));
558    }
559
560    /// `supports_caps_ex` is a bit-wise AND.
561    #[test]
562    fn caps_ex_bit_test() {
563        let caps = ConnectCapabilities {
564            caps_ex: CAPS_EX_RECONNECT | CAPS_EX_MOD_EX,
565            ..Default::default()
566        };
567        assert!(caps.supports_caps_ex(CAPS_EX_RECONNECT));
568        assert!(caps.supports_caps_ex(CAPS_EX_MOD_EX));
569        assert!(!caps.supports_caps_ex(CAPS_EX_MULTITRACK));
570        // Combined-mask test: requires BOTH bits set.
571        assert!(caps.supports_caps_ex(CAPS_EX_RECONNECT | CAPS_EX_MOD_EX));
572    }
573
574    /// A forged Object whose `capsEx` is a String parses cleanly with
575    /// the rest of the block intact.
576    #[test]
577    fn malformed_caps_ex_falls_back_to_default() {
578        let obj = Amf0Value::Object(vec![
579            ("capsEx".into(), Amf0Value::String("oops".into())),
580            (
581                "fourCcList".into(),
582                Amf0Value::StrictArray(vec![Amf0Value::String("av01".into())]),
583            ),
584        ]);
585        let caps = ConnectCapabilities::from_amf0(&obj);
586        assert_eq!(caps.caps_ex, 0);
587        assert_eq!(caps.fourcc_list, vec!["av01"]);
588    }
589
590    /// Non-object inputs return an empty block (a forwarder may pass a
591    /// pre-resolved capability struct in via a top-level Number for
592    /// instance — that's silently ignored).
593    #[test]
594    fn from_amf0_non_object_returns_empty() {
595        let caps = ConnectCapabilities::from_amf0(&Amf0Value::Number(7.0));
596        assert!(caps.is_empty());
597        let caps = ConnectCapabilities::from_amf0(&Amf0Value::Null);
598        assert!(caps.is_empty());
599    }
600
601    /// `objectEncoding` must round-trip the documented 0 / 3 values.
602    #[test]
603    fn object_encoding_values() {
604        for &enc in &[OBJECT_ENCODING_AMF0, OBJECT_ENCODING_AMF3] {
605            let caps = ConnectCapabilities {
606                object_encoding: Some(enc),
607                ..Default::default()
608            };
609            let mut pairs = Vec::new();
610            caps.encode_into(&mut pairs);
611            let back = ConnectCapabilities::from_amf0(&Amf0Value::Object(pairs));
612            assert_eq!(back.object_encoding, Some(enc));
613        }
614    }
615
616    /// An ECMA-array carrying the capability properties parses the same
617    /// way an Object does. Some commodity peers use ECMA-array for the
618    /// `_result` properties slot.
619    #[test]
620    fn ecma_array_parses_as_capability_block() {
621        let arr = Amf0Value::EcmaArray(vec![
622            ("capsEx".into(), Amf0Value::Number(0x05 as f64)),
623            (
624                "videoFourCcInfoMap".into(),
625                Amf0Value::Object(vec![("hvc1".into(), Amf0Value::Number(3.0))]),
626            ),
627        ]);
628        let caps = ConnectCapabilities::from_amf0(&arr);
629        assert_eq!(caps.caps_ex, 0x05);
630        assert_eq!(caps.video_fourcc_info_map.get("hvc1"), Some(3));
631    }
632}