Skip to main content

truce_core/
ump.rs

1//! Universal MIDI Packet (UMP) codec for MIDI 2.0 channel-voice
2//! messages.
3//!
4//! UMP is the MIDI 2.0 wire format. Channel-voice 2.0 messages live
5//! in 64-bit packets: word 0 carries `mt | group | status |
6//! channel | <status-specific 16 bits>`, word 1 carries the
7//! status-specific value (velocity + attribute, controller value,
8//! pitch-bend, etc.). Many UMP transports embed 64-bit packets in a
9//! fixed 128-bit slot, so [`decode_ump_channel_voice_2`] works in
10//! terms of `[u32; 4]` with the upper two words zeroed for
11//! channel-voice. The matching encoder is intentionally not exposed
12//! today - see the note next to the decoder.
13//!
14//! Spec reference: MIDI 2.0 M2-104-UM §4.1.
15//!
16//! Format wrappers that speak UMP (AU v3 on iOS 17+ / macOS 14+ via
17//! `MIDIEventList`, CLAP's `CLAP_EVENT_MIDI2`) call into here so the
18//! channel-voice + `SysEx` decoders aren't reimplemented per
19//! wrapper.
20//!
21//! Out of scope today: utility messages (mt 0x0), system real-time
22//! (mt 0x1), legacy MIDI 1.0 channel-voice over UMP (mt 0x2),
23//! `SysEx`-7 (mt 0x3), data messages (mt 0x5), flex-data (mt 0xD),
24//! UMP stream (mt 0xF). `SysEx` over UMP has its own assembler;
25//! everything else awaits demand from a wrapper that needs it.
26
27use crate::events::EventBody;
28
29/// UMP message type for MIDI 2.0 channel-voice messages.
30const MT_CHANNEL_VOICE_2: u8 = 0x4;
31/// UMP message type for 7-bit `SysEx` payloads.
32const MT_SYSEX_7: u8 = 0x3;
33/// UMP message type for 8-bit `SysEx` / data payloads.
34const MT_SYSEX_8: u8 = 0x5;
35
36/// `SysEx` packet status nibble shared by `SysEx`-7 (mt 0x3) and
37/// `SysEx`-8 (mt 0x5). Lives in word 0 bits 23..20.
38const SYSEX_STATUS_COMPLETE: u8 = 0x0;
39const SYSEX_STATUS_START: u8 = 0x1;
40const SYSEX_STATUS_CONTINUE: u8 = 0x2;
41const SYSEX_STATUS_END: u8 = 0x3;
42
43/// Decode the first two words of a UMP packet into a MIDI 2.0
44/// channel-voice [`EventBody`]. Returns `None` for non-channel-voice
45/// packets (utility, system, `SysEx`, data) - those are handled by
46/// dedicated decoders (or not at all). `words[2]` and `words[3]` are
47/// ignored - channel-voice 2.0 is 64 bits and the upper half of a
48/// 128-bit slot is undefined for it.
49#[must_use]
50#[allow(clippy::cast_possible_truncation)] // UMP fields are bit-packed; truncation is intentional
51pub fn decode_ump_channel_voice_2(words: [u32; 4]) -> Option<EventBody> {
52    // Bit layout (word 0):
53    //   31..28 mt (message type, 0x4 = MIDI 2.0 CV)
54    //   27..24 group (0..=15)
55    //   23..20 status nibble (0x8 = NoteOff, 0x9 = NoteOn, ...)
56    //   19..16 channel (0..=15)
57    //   15..0  status-specific (note + attribute-type, cc number, ...)
58    let w0 = words[0];
59    let w1 = words[1];
60    let mt = ((w0 >> 28) & 0xF) as u8;
61    if mt != MT_CHANNEL_VOICE_2 {
62        return None;
63    }
64    let group = ((w0 >> 24) & 0xF) as u8;
65    let status = ((w0 >> 20) & 0xF) as u8;
66    let channel = ((w0 >> 16) & 0xF) as u8;
67    let byte_a = ((w0 >> 8) & 0xFF) as u8; // note / cc number / etc.
68    let byte_b = (w0 & 0xFF) as u8; // attribute-type / index / etc.
69    let body = match status {
70        0x8 => EventBody::NoteOff2 {
71            group,
72            channel,
73            note: byte_a & 0x7F,
74            velocity: (w1 >> 16) as u16,
75            attribute_type: byte_b,
76            attribute: (w1 & 0xFFFF) as u16,
77        },
78        0x9 => EventBody::NoteOn2 {
79            group,
80            channel,
81            note: byte_a & 0x7F,
82            velocity: (w1 >> 16) as u16,
83            attribute_type: byte_b,
84            attribute: (w1 & 0xFFFF) as u16,
85        },
86        0xA => EventBody::PolyPressure2 {
87            group,
88            channel,
89            note: byte_a & 0x7F,
90            pressure: w1,
91        },
92        // 0x0 = Registered Per-Note (RPN-like), 0x1 = Assignable
93        // Per-Note. MIDI 2.0 §4.1.4. The lower 8 bits of word 0
94        // carry the per-note controller index; word 1 is the value.
95        0x0 | 0x1 => EventBody::PerNoteCC {
96            group,
97            channel,
98            note: byte_a & 0x7F,
99            cc: byte_b,
100            value: w1,
101            registered: status == 0x0,
102        },
103        // 0x6 = Per-Note Pitch Bend.
104        0x6 => EventBody::PerNotePitchBend {
105            group,
106            channel,
107            note: byte_a & 0x7F,
108            value: w1,
109        },
110        // 0xF = Per-Note Management. The flags live in byte_b (per
111        // §4.1.6); only the low two bits are defined today.
112        0xF => EventBody::PerNoteManagement {
113            group,
114            channel,
115            note: byte_a & 0x7F,
116            flags: byte_b,
117        },
118        0xB => EventBody::ControlChange2 {
119            group,
120            channel,
121            cc: byte_a & 0x7F,
122            value: w1,
123        },
124        0xD => EventBody::ChannelPressure2 {
125            group,
126            channel,
127            pressure: w1,
128        },
129        0xE => EventBody::PitchBend2 {
130            group,
131            channel,
132            value: w1,
133        },
134        // 0x2 = Registered Controller (RPN), 0x3 = Assignable
135        // Controller (NRPN). Bank lives in `byte_a` (lower 7 bits),
136        // index in `byte_b` (lower 7 bits).
137        0x2 => EventBody::RegisteredController {
138            group,
139            channel,
140            bank: byte_a & 0x7F,
141            index: byte_b & 0x7F,
142            value: w1,
143        },
144        0x3 => EventBody::AssignableController {
145            group,
146            channel,
147            bank: byte_a & 0x7F,
148            index: byte_b & 0x7F,
149            value: w1,
150        },
151        0xC => EventBody::ProgramChange2 {
152            group,
153            channel,
154            program: (w1 >> 24) as u8 & 0x7F,
155            // Word 0 bit 0 carries the "B" (bank-valid) flag; the
156            // bank bytes live in word 1's bottom 16 bits (MSB then
157            // LSB).
158            bank: if w0 & 0x01 == 1 {
159                Some(((w1 >> 8) as u8 & 0x7F, w1 as u8 & 0x7F))
160            } else {
161                None
162            },
163        },
164        _ => return None,
165    };
166    Some(body)
167}
168
169// The matching `encode_ump_channel_voice_2` is intentionally
170// absent: until plug-ins can declare a MIDI version preference
171// that wrappers honour at port-negotiation time, no wrapper has
172// a sanctioned path to emit MIDI 2.0 channel-voice events, and
173// shipping a dormant encoder would invite ad-hoc callers that
174// bypass the eventual downconvert gate.
175
176/// One reassembled `SysEx` payload, in the form
177/// [`crate::events::EventList::push_sysex`] expects: just the inner
178/// bytes (no leading `0xF0`, no trailing `0xF7`), plus the UMP
179/// routing keys (`group` + `stream_id`) for callers that care
180/// about per-stream demux. `stream_id` is always 0 for `SysEx`-7
181/// (the format has no stream identifier).
182pub struct SysExPacket<'a> {
183    /// UMP group (0..=15) the message arrived on.
184    pub group: u8,
185    /// `SysEx`-8 stream identifier (0..=255); always 0 for
186    /// `SysEx`-7.
187    pub stream_id: u8,
188    /// The reassembled inner bytes. Valid until the next call into
189    /// the assembler.
190    pub bytes: &'a [u8],
191}
192
193/// What [`SysExAssembler::push_sysex7_packet`] /
194/// [`SysExAssembler::push_sysex8_packet`] does with the input UMP.
195pub enum SysExFeed<'a> {
196    /// Packet was a `Continue` / `Start` - buffered, nothing to
197    /// emit yet.
198    Buffered,
199    /// Packet was `Complete` or `End` - `payload` is ready to push
200    /// to the host's event list. The slice is invalidated by the
201    /// next call into the assembler.
202    Complete(SysExPacket<'a>),
203    /// Packet was malformed (length > 6 for `SysEx`-7, > 13 for
204    /// `SysEx`-8, or status nibble we don't recognise). Caller
205    /// should drop the message; assembler state is unchanged.
206    Invalid,
207    /// Buffer overflowed before the `End` packet arrived. The
208    /// partial message has been dropped; the caller may want to
209    /// surface this via a counter.
210    Overflow,
211}
212
213/// Maximum number of concurrent `SysEx` streams the assembler
214/// reassembles in parallel. `(group, stream_id)` identifies each
215/// stream uniquely; a fifth concurrent stream evicts the
216/// least-recently-touched one (dropping its in-progress message).
217///
218/// 4 is enough for any host pattern we've observed: a single
219/// MIDI 2.0 host typically uses one stream per group, and four
220/// simultaneously-active groups is already past the realistic
221/// upper bound for `SysEx` traffic in a single audio block.
222pub const SYSEX_ASSEMBLER_SLOTS: usize = 4;
223
224struct StreamSlot {
225    /// Pre-allocated buffer for this slot's in-progress message.
226    /// Sized at construction; never grows on the audio thread.
227    buffer: Vec<u8>,
228    group: u8,
229    stream_id: u8,
230    /// `true` between `Start` and `End`; `false` when the slot
231    /// holds the bytes from a just-completed `Complete` / `End`
232    /// (waiting for the caller to read them before reuse).
233    in_progress: bool,
234    /// `true` when the slot is allocated to a stream. Distinct
235    /// from `in_progress` so a just-completed slot can hold its
236    /// bytes for the caller's borrow without being evictable on
237    /// the same call.
238    in_use: bool,
239    /// Monotonic counter set on every packet that touches the
240    /// slot, used as the LRU key when allocation needs to evict.
241    last_touch: u64,
242}
243
244/// Stateful reassembler for UMP `SysEx` streams.
245///
246/// Maintains [`SYSEX_ASSEMBLER_SLOTS`] independent buffers, each
247/// keyed by `(group, stream_id)`, so hosts that interleave
248/// `SysEx` traffic across UMP groups (or across `SysEx`-8 streams
249/// within one group) don't see corrupt concatenations.
250///
251/// Each slot's buffer is bounded by the per-slot capacity passed
252/// to [`Self::with_capacity`]; pushing past it returns
253/// [`SysExFeed::Overflow`] and discards that slot's partial
254/// message - truncated `SysEx` is corrupt by definition.
255pub struct SysExAssembler {
256    slots: [StreamSlot; SYSEX_ASSEMBLER_SLOTS],
257    /// Monotonically increases on every packet; used to break ties
258    /// when LRU-evicting a slot to make room for a new stream.
259    touch_counter: u64,
260}
261
262impl SysExAssembler {
263    /// Allocate per-slot buffers up front. `capacity` is the
264    /// largest `SysEx` payload (in bytes) **per stream** the
265    /// assembler will accept - total memory is
266    /// `SYSEX_ASSEMBLER_SLOTS × capacity`.
267    ///
268    /// Matching `capacity` to the consuming
269    /// [`crate::events::EventList::sysex_pool_capacity`] is the
270    /// typical choice; smaller values trade memory for the
271    /// maximum single-message length.
272    #[must_use]
273    pub fn with_capacity(capacity: usize) -> Self {
274        // Per-slot init done by array literal - each `StreamSlot`
275        // allocates its own `Vec::with_capacity(capacity)`.
276        let slots = std::array::from_fn(|_| StreamSlot {
277            buffer: Vec::with_capacity(capacity),
278            group: 0,
279            stream_id: 0,
280            in_progress: false,
281            in_use: false,
282            last_touch: 0,
283        });
284        Self {
285            slots,
286            touch_counter: 0,
287        }
288    }
289
290    /// Drop every in-progress message and free every slot. Call
291    /// between `process()` blocks when the host's contract
292    /// guarantees no `SysEx` continues across the block boundary,
293    /// or on the first packet of a fresh session.
294    pub fn reset(&mut self) {
295        for slot in &mut self.slots {
296            slot.buffer.clear();
297            slot.in_progress = false;
298            slot.in_use = false;
299            slot.last_touch = 0;
300        }
301        self.touch_counter = 0;
302    }
303
304    /// Find the slot currently servicing `(group, stream_id)`, or
305    /// `None` if no slot matches. Returns the slot index.
306    fn find_slot(&self, group: u8, stream_id: u8) -> Option<usize> {
307        self.slots
308            .iter()
309            .position(|s| s.in_use && s.group == group && s.stream_id == stream_id)
310    }
311
312    /// Claim a slot for `(group, stream_id)` - preferring an empty
313    /// one, falling back to LRU eviction. Eviction drops the
314    /// victim's in-progress message (we have no way to surface
315    /// the loss back to the host other than the eventual missing
316    /// final message).
317    fn claim_slot(&mut self, group: u8, stream_id: u8) -> usize {
318        // Pick: empty slot if any; otherwise the least-recently-
319        // touched one. `unwrap` on the LRU fallback is safe because
320        // the slot table is fixed-size and non-empty by construction.
321        let idx = self
322            .slots
323            .iter()
324            .position(|s| !s.in_use)
325            .unwrap_or_else(|| {
326                self.slots
327                    .iter()
328                    .enumerate()
329                    .min_by_key(|(_, s)| s.last_touch)
330                    .map(|(i, _)| i)
331                    .expect("non-empty slot table")
332            });
333        let slot = &mut self.slots[idx];
334        slot.buffer.clear();
335        slot.group = group;
336        slot.stream_id = stream_id;
337        slot.in_use = true;
338        slot.in_progress = false;
339        idx
340    }
341
342    /// Feed one `SysEx`-7 UMP (`words[0]`, `words[1]`). Group is
343    /// extracted from word 0 bits 27..24; `stream_id` is always 0
344    /// (the format reserves no slot for it).
345    #[allow(clippy::cast_possible_truncation)] // UMP bit-packing
346    pub fn push_sysex7_packet(&mut self, words: [u32; 2]) -> SysExFeed<'_> {
347        let w0 = words[0];
348        let w1 = words[1];
349        let mt = ((w0 >> 28) & 0xF) as u8;
350        if mt != MT_SYSEX_7 {
351            return SysExFeed::Invalid;
352        }
353        let group = ((w0 >> 24) & 0xF) as u8;
354        let status = ((w0 >> 20) & 0xF) as u8;
355        let n = ((w0 >> 16) & 0xF) as u8;
356        if n > 6 {
357            return SysExFeed::Invalid;
358        }
359        // Bytes packed into the bottom 16 bits of w0 + all of w1 -
360        // each in its own 8-bit slot, top bit always 0 per spec.
361        let raw = [
362            ((w0 >> 8) & 0xFF) as u8,
363            (w0 & 0xFF) as u8,
364            ((w1 >> 24) & 0xFF) as u8,
365            ((w1 >> 16) & 0xFF) as u8,
366            ((w1 >> 8) & 0xFF) as u8,
367            (w1 & 0xFF) as u8,
368        ];
369        self.feed_payload(group, 0, status, &raw[..n as usize])
370    }
371
372    /// Feed one `SysEx`-8 UMP (all four words). Group at word 0
373    /// bits 27..24; `stream_id` at word 0 bits 15..8 (`SysEx`-8
374    /// reserves one byte for a per-group stream identifier so
375    /// hosts can interleave concurrent `SysEx` payloads).
376    #[allow(clippy::cast_possible_truncation)] // UMP bit-packing
377    pub fn push_sysex8_packet(&mut self, words: [u32; 4]) -> SysExFeed<'_> {
378        let w0 = words[0];
379        let mt = ((w0 >> 28) & 0xF) as u8;
380        if mt != MT_SYSEX_8 {
381            return SysExFeed::Invalid;
382        }
383        let group = ((w0 >> 24) & 0xF) as u8;
384        let status = ((w0 >> 20) & 0xF) as u8;
385        let n = ((w0 >> 16) & 0xF) as u8;
386        let stream_id = ((w0 >> 8) & 0xFF) as u8;
387        // `SysEx`-8 reserves 1 byte for `stream_id`, leaving 13
388        // bytes for payload; `n` is the count of those payload
389        // bytes used in this packet.
390        if n > 13 {
391            return SysExFeed::Invalid;
392        }
393        // word 0: stream_id at bits 15..8, byte 0 at bits 7..0
394        // words 1..3: bytes 1..12, MSB-first
395        let raw = [
396            (w0 & 0xFF) as u8, // byte 0
397            ((words[1] >> 24) & 0xFF) as u8,
398            ((words[1] >> 16) & 0xFF) as u8,
399            ((words[1] >> 8) & 0xFF) as u8,
400            (words[1] & 0xFF) as u8,
401            ((words[2] >> 24) & 0xFF) as u8,
402            ((words[2] >> 16) & 0xFF) as u8,
403            ((words[2] >> 8) & 0xFF) as u8,
404            (words[2] & 0xFF) as u8,
405            ((words[3] >> 24) & 0xFF) as u8,
406            ((words[3] >> 16) & 0xFF) as u8,
407            ((words[3] >> 8) & 0xFF) as u8,
408            (words[3] & 0xFF) as u8,
409        ];
410        self.feed_payload(group, stream_id, status, &raw[..n as usize])
411    }
412
413    fn feed_payload(
414        &mut self,
415        group: u8,
416        stream_id: u8,
417        status: u8,
418        bytes: &[u8],
419    ) -> SysExFeed<'_> {
420        self.touch_counter += 1;
421        let now = self.touch_counter;
422
423        match status {
424            SYSEX_STATUS_COMPLETE => {
425                // Single-packet message - claim a slot, fill it,
426                // mark it not-in-progress so the next call can
427                // evict it. Reuse any existing slot for this
428                // (group, stream_id) (in case the previous stream
429                // for this pair leaked an in-progress state).
430                let idx = match self.find_slot(group, stream_id) {
431                    Some(i) => i,
432                    None => self.claim_slot(group, stream_id),
433                };
434                let slot = &mut self.slots[idx];
435                slot.buffer.clear();
436                if slot.buffer.capacity() < bytes.len() {
437                    // Release the slot on overflow so the next call
438                    // can reclaim it - otherwise an oversize
439                    // `Complete` would leave an `in_use` slot
440                    // occupying the table forever (until LRU evicted
441                    // it manually). Mirror the `Start` arm.
442                    slot.in_progress = false;
443                    slot.in_use = false;
444                    slot.last_touch = now;
445                    return SysExFeed::Overflow;
446                }
447                slot.buffer.extend_from_slice(bytes);
448                slot.in_progress = false;
449                slot.last_touch = now;
450                SysExFeed::Complete(SysExPacket {
451                    group,
452                    stream_id,
453                    bytes: &slot.buffer,
454                })
455            }
456            SYSEX_STATUS_START => {
457                let idx = match self.find_slot(group, stream_id) {
458                    Some(i) => i,
459                    None => self.claim_slot(group, stream_id),
460                };
461                let slot = &mut self.slots[idx];
462                slot.buffer.clear();
463                if slot.buffer.capacity() < bytes.len() {
464                    slot.in_progress = false;
465                    slot.in_use = false;
466                    slot.last_touch = now;
467                    return SysExFeed::Overflow;
468                }
469                slot.buffer.extend_from_slice(bytes);
470                slot.in_progress = true;
471                slot.last_touch = now;
472                SysExFeed::Buffered
473            }
474            SYSEX_STATUS_CONTINUE | SYSEX_STATUS_END => {
475                let Some(idx) = self.find_slot(group, stream_id) else {
476                    // Out-of-band continuation - drop.
477                    return SysExFeed::Invalid;
478                };
479                let slot = &mut self.slots[idx];
480                if !slot.in_progress {
481                    return SysExFeed::Invalid;
482                }
483                if slot.buffer.len() + bytes.len() > slot.buffer.capacity() {
484                    slot.buffer.clear();
485                    slot.in_progress = false;
486                    slot.in_use = false;
487                    slot.last_touch = now;
488                    return SysExFeed::Overflow;
489                }
490                slot.buffer.extend_from_slice(bytes);
491                slot.last_touch = now;
492                if status == SYSEX_STATUS_END {
493                    slot.in_progress = false;
494                    SysExFeed::Complete(SysExPacket {
495                        group,
496                        stream_id,
497                        bytes: &slot.buffer,
498                    })
499                } else {
500                    SysExFeed::Buffered
501                }
502            }
503            _ => SysExFeed::Invalid,
504        }
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn decode_note_on_2() {
514        // Hand-crafted UMP 2.0 channel-voice NoteOn:
515        // mt=0x4, group=0, status=0x9, channel=2, note=60,
516        // velocity=0x8000, attribute_type=3, attribute=0x1234.
517        let w0 = (0x4u32 << 28) | (0x9u32 << 20) | (0x2u32 << 16) | (60u32 << 8) | 0x03;
518        let w1 = (0x8000u32 << 16) | 0x1234;
519        let decoded = decode_ump_channel_voice_2([w0, w1, 0, 0]).expect("decodes");
520        if let EventBody::NoteOn2 {
521            channel,
522            note,
523            velocity,
524            attribute_type,
525            attribute,
526            ..
527        } = decoded
528        {
529            assert_eq!(channel, 2);
530            assert_eq!(note, 60);
531            assert_eq!(velocity, 0x8000);
532            assert_eq!(attribute_type, 3);
533            assert_eq!(attribute, 0x1234);
534        } else {
535            panic!("expected NoteOn2");
536        }
537    }
538
539    #[test]
540    fn non_channel_voice_packet_returns_none() {
541        // mt = 0x0 (utility)
542        assert!(decode_ump_channel_voice_2([0x0000_0000, 0, 0, 0]).is_none());
543        // mt = 0x3 (SysEx-7)
544        assert!(decode_ump_channel_voice_2([0x3000_0000, 0, 0, 0]).is_none());
545    }
546
547    // -- SysEx-7 assembler --
548
549    fn sysex7_packet(status: u8, bytes: &[u8]) -> [u32; 2] {
550        assert!(bytes.len() <= 6);
551        // assert above bounds `len()` to 0..=6, well within `u32`.
552        #[allow(clippy::cast_possible_truncation)]
553        let n = bytes.len() as u32;
554        let mut padded = [0u8; 6];
555        padded[..bytes.len()].copy_from_slice(bytes);
556        // group is implicitly 0 - `<< 24` would be a no-op so we omit it.
557        let w0 = (0x3u32 << 28)
558            | (u32::from(status) << 20)
559            | (n << 16)
560            | (u32::from(padded[0]) << 8)
561            | u32::from(padded[1]);
562        let w1 = (u32::from(padded[2]) << 24)
563            | (u32::from(padded[3]) << 16)
564            | (u32::from(padded[4]) << 8)
565            | u32::from(padded[5]);
566        [w0, w1]
567    }
568
569    #[test]
570    fn assembler_single_complete_packet() {
571        let mut a = SysExAssembler::with_capacity(64);
572        let packet = sysex7_packet(SYSEX_STATUS_COMPLETE, &[0x7E, 0x00, 0x06, 0x01]);
573        match a.push_sysex7_packet(packet) {
574            SysExFeed::Complete(p) => assert_eq!(p.bytes, &[0x7E, 0x00, 0x06, 0x01]),
575            _ => panic!("expected Complete"),
576        }
577    }
578
579    #[test]
580    fn assembler_multi_packet_reassembly() {
581        let mut a = SysExAssembler::with_capacity(64);
582        // Start: 6 bytes.
583        let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3, 4, 5, 6]);
584        assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
585        // Continue: 6 more bytes.
586        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[7, 8, 9, 10, 11, 12]);
587        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Buffered));
588        // End: 3 bytes.
589        let end = sysex7_packet(SYSEX_STATUS_END, &[13, 14, 15]);
590        match a.push_sysex7_packet(end) {
591            SysExFeed::Complete(p) => assert_eq!(
592                p.bytes,
593                &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
594            ),
595            _ => panic!("expected Complete"),
596        }
597    }
598
599    #[test]
600    fn assembler_overflow_returns_overflow_and_drops_partial() {
601        let mut a = SysExAssembler::with_capacity(8); // tiny
602        let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3, 4, 5, 6]);
603        assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
604        // 6 + 6 > 8 → overflow.
605        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[7, 8, 9, 10, 11, 12]);
606        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Overflow));
607        // After overflow the assembler is reset; a fresh Start works.
608        let start2 = sysex7_packet(SYSEX_STATUS_COMPLETE, &[42]);
609        match a.push_sysex7_packet(start2) {
610            SysExFeed::Complete(p) => assert_eq!(p.bytes, &[42]),
611            _ => panic!("expected Complete after reset"),
612        }
613    }
614
615    #[test]
616    fn assembler_continue_without_start_is_invalid() {
617        let mut a = SysExAssembler::with_capacity(64);
618        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[1, 2, 3]);
619        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Invalid));
620    }
621
622    #[test]
623    fn assembler_complete_overflow_releases_slot() {
624        // Per-slot capacity smaller than a single COMPLETE message.
625        // After the overflow, the slot must be releasable so a
626        // later stream on a fresh `(group, stream_id)` can still
627        // claim it instead of getting LRU-evicted.
628        let mut a = SysExAssembler::with_capacity(4);
629        let oversize = sysex7_packet(SYSEX_STATUS_COMPLETE, &[1, 2, 3, 4, 5]);
630        assert!(matches!(
631            a.push_sysex7_packet(oversize),
632            SysExFeed::Overflow
633        ));
634        // Three more streams on distinct groups should now all
635        // claim cleanly: the overflowed slot is back in the pool.
636        for group in 1..=3u8 {
637            let p = sysex7_packet_for_group(group, SYSEX_STATUS_START, &[group]);
638            assert!(matches!(a.push_sysex7_packet(p), SysExFeed::Buffered));
639        }
640        // And the fourth too - confirms total slot count is `SYSEX_ASSEMBLER_SLOTS`,
641        // not `SYSEX_ASSEMBLER_SLOTS - 1`.
642        let p = sysex7_packet_for_group(4, SYSEX_STATUS_START, &[4]);
643        assert!(matches!(a.push_sysex7_packet(p), SysExFeed::Buffered));
644    }
645
646    #[test]
647    fn assembler_reset_drops_partial() {
648        let mut a = SysExAssembler::with_capacity(64);
649        let start = sysex7_packet(SYSEX_STATUS_START, &[1, 2, 3]);
650        assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
651        a.reset();
652        // After reset, a fresh Continue should fail (no in-progress).
653        let cont = sysex7_packet(SYSEX_STATUS_CONTINUE, &[4]);
654        assert!(matches!(a.push_sysex7_packet(cont), SysExFeed::Invalid));
655    }
656
657    #[test]
658    fn assembler_sysex8_complete_packet() {
659        let mut a = SysExAssembler::with_capacity(64);
660        // SysEx-8: mt=0x5, status=0 (complete), n=4, stream_id=0,
661        // payload [0xAA, 0xBB, 0xCC, 0xDD] in bytes 0..3.
662        // status=0 means we don't need to shift anything into bits 23..20.
663        let w0 = (0x5u32 << 28) | (4u32 << 16) | 0xAA;
664        let w1 = (0xBBu32 << 24) | (0xCCu32 << 16) | (0xDDu32 << 8);
665        match a.push_sysex8_packet([w0, w1, 0, 0]) {
666            SysExFeed::Complete(p) => {
667                assert_eq!(p.bytes, &[0xAA, 0xBB, 0xCC, 0xDD]);
668                assert_eq!(p.group, 0);
669                assert_eq!(p.stream_id, 0);
670            }
671            _ => panic!("expected Complete"),
672        }
673    }
674
675    // Helper: build a SysEx-7 packet with explicit group.
676    fn sysex7_packet_for_group(group: u8, status: u8, bytes: &[u8]) -> [u32; 2] {
677        assert!(bytes.len() <= 6);
678        #[allow(clippy::cast_possible_truncation)]
679        let n = bytes.len() as u32;
680        let mut padded = [0u8; 6];
681        padded[..bytes.len()].copy_from_slice(bytes);
682        let w0 = (0x3u32 << 28)
683            | (u32::from(group & 0xF) << 24)
684            | (u32::from(status) << 20)
685            | (n << 16)
686            | (u32::from(padded[0]) << 8)
687            | u32::from(padded[1]);
688        let w1 = (u32::from(padded[2]) << 24)
689            | (u32::from(padded[3]) << 16)
690            | (u32::from(padded[4]) << 8)
691            | u32::from(padded[5]);
692        [w0, w1]
693    }
694
695    #[test]
696    fn assembler_concurrent_streams_across_groups() {
697        // Two SysEx-7 streams interleaved on groups 3 and 7. Both
698        // should reassemble independently; neither's bytes should
699        // bleed into the other.
700        let mut a = SysExAssembler::with_capacity(64);
701
702        // Group 3: Start with [0x10, 0x11].
703        let g3_start = sysex7_packet_for_group(3, SYSEX_STATUS_START, &[0x10, 0x11]);
704        assert!(matches!(
705            a.push_sysex7_packet(g3_start),
706            SysExFeed::Buffered
707        ));
708
709        // Group 7: Start with [0x20, 0x21, 0x22].
710        let g7_start = sysex7_packet_for_group(7, SYSEX_STATUS_START, &[0x20, 0x21, 0x22]);
711        assert!(matches!(
712            a.push_sysex7_packet(g7_start),
713            SysExFeed::Buffered
714        ));
715
716        // Group 3: End with [0x12].
717        let g3_end = sysex7_packet_for_group(3, SYSEX_STATUS_END, &[0x12]);
718        match a.push_sysex7_packet(g3_end) {
719            SysExFeed::Complete(p) => {
720                assert_eq!(p.group, 3);
721                assert_eq!(p.bytes, &[0x10, 0x11, 0x12]);
722            }
723            _ => panic!("expected Complete on group 3"),
724        }
725
726        // Group 7: End with [0x23, 0x24].
727        let g7_end = sysex7_packet_for_group(7, SYSEX_STATUS_END, &[0x23, 0x24]);
728        match a.push_sysex7_packet(g7_end) {
729            SysExFeed::Complete(p) => {
730                assert_eq!(p.group, 7);
731                assert_eq!(p.bytes, &[0x20, 0x21, 0x22, 0x23, 0x24]);
732            }
733            _ => panic!("expected Complete on group 7"),
734        }
735    }
736
737    #[test]
738    fn assembler_sysex8_stream_id_isolates_concurrent_streams() {
739        // Two SysEx-8 streams on the same group (0) but different
740        // stream_ids (5 and 9), interleaved.
741        let mut a = SysExAssembler::with_capacity(64);
742
743        // Helper to build a SysEx-8 packet with explicit
744        // status / n / stream_id / first 4 payload bytes (the
745        // assembler only reads the first `n` payload bytes).
746        let mk = |status: u8, n: u32, stream_id: u8, bytes: [u8; 4]| -> [u32; 4] {
747            let w0 = (0x5u32 << 28)
748                | (u32::from(status) << 20)
749                | (n << 16)
750                | (u32::from(stream_id) << 8)
751                | u32::from(bytes[0]);
752            let w1 = (u32::from(bytes[1]) << 24)
753                | (u32::from(bytes[2]) << 16)
754                | (u32::from(bytes[3]) << 8);
755            [w0, w1, 0, 0]
756        };
757
758        // stream 5 Start: 4 bytes [0xA0, 0xA1, 0xA2, 0xA3]
759        assert!(matches!(
760            a.push_sysex8_packet(mk(SYSEX_STATUS_START, 4, 5, [0xA0, 0xA1, 0xA2, 0xA3])),
761            SysExFeed::Buffered
762        ));
763        // stream 9 Start: 4 bytes [0xB0, 0xB1, 0xB2, 0xB3]
764        assert!(matches!(
765            a.push_sysex8_packet(mk(SYSEX_STATUS_START, 4, 9, [0xB0, 0xB1, 0xB2, 0xB3])),
766            SysExFeed::Buffered
767        ));
768        // stream 5 End: 1 byte [0xA4]
769        match a.push_sysex8_packet(mk(SYSEX_STATUS_END, 1, 5, [0xA4, 0, 0, 0])) {
770            SysExFeed::Complete(p) => {
771                assert_eq!(p.stream_id, 5);
772                assert_eq!(p.bytes, &[0xA0, 0xA1, 0xA2, 0xA3, 0xA4]);
773            }
774            _ => panic!("expected Complete on stream 5"),
775        }
776        // stream 9 End: 2 bytes [0xB4, 0xB5]
777        match a.push_sysex8_packet(mk(SYSEX_STATUS_END, 2, 9, [0xB4, 0xB5, 0, 0])) {
778            SysExFeed::Complete(p) => {
779                assert_eq!(p.stream_id, 9);
780                assert_eq!(p.bytes, &[0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5]);
781            }
782            _ => panic!("expected Complete on stream 9"),
783        }
784    }
785
786    #[test]
787    fn assembler_lru_evicts_when_slots_exhausted() {
788        // Start more concurrent streams than slots exist; the
789        // oldest in-progress should be evicted.
790        let slots_u8 = u8::try_from(SYSEX_ASSEMBLER_SLOTS).expect("slot count fits u8");
791        let mut a = SysExAssembler::with_capacity(64);
792        for group in 0..slots_u8 {
793            let start = sysex7_packet_for_group(group, SYSEX_STATUS_START, &[group]);
794            assert!(matches!(a.push_sysex7_packet(start), SysExFeed::Buffered));
795        }
796        // One more - must evict group 0 (LRU).
797        let new_group = slots_u8;
798        let evictor = sysex7_packet_for_group(new_group, SYSEX_STATUS_START, &[new_group]);
799        assert!(matches!(a.push_sysex7_packet(evictor), SysExFeed::Buffered));
800        // Group 0's End should now fail (its slot got reused).
801        let g0_end = sysex7_packet_for_group(0, SYSEX_STATUS_END, &[0x99]);
802        assert!(matches!(a.push_sysex7_packet(g0_end), SysExFeed::Invalid));
803        // The evictor's End should work.
804        let new_end = sysex7_packet_for_group(new_group, SYSEX_STATUS_END, &[0xEE]);
805        match a.push_sysex7_packet(new_end) {
806            SysExFeed::Complete(p) => {
807                assert_eq!(p.group, new_group);
808                assert_eq!(p.bytes, &[new_group, 0xEE]);
809            }
810            _ => panic!("expected Complete on evicting group"),
811        }
812    }
813}