Skip to main content

martensite_plugin/
ring_buffer.rs

1//! Zero-allocation shared-memory ring buffer for plugin paint commands.
2//!
3//! The host and the WebAssembly guest share a single linear memory region.
4//! The guest writes raw `PluginPaintCmd` records followed by their payload
5//! directly into the buffer, and the host consumes them by parsing in place,
6//! avoiding any per-frame allocation or serialization overhead.
7
8use std::fmt;
9
10use tracing::error;
11
12/// Default size of the shared linear memory ring buffer (256 KiB).
13///
14/// # Examples
15///
16/// ```
17/// use martensite_plugin::DEFAULT_CAPACITY;
18///
19/// assert_eq!(DEFAULT_CAPACITY, 256 * 1024);
20/// ```
21pub const DEFAULT_CAPACITY: usize = 256 * 1024;
22
23/// Number of leading bytes reserved for the producer/consumer cursors in a
24/// shared ring region.
25///
26/// When the ring buffer lives inside the guest's linear memory, the first
27/// [`SHARED_HEADER_SIZE`] bytes hold the `head` (consumer cursor) and `tail`
28/// (producer cursor) as little-endian `u32` values so that both sides can
29/// observe the buffer state without a host call. The remaining bytes are the
30/// circular payload area.
31///
32/// # Examples
33///
34/// ```
35/// use martensite_plugin::ring_buffer::SHARED_HEADER_SIZE;
36///
37/// assert_eq!(SHARED_HEADER_SIZE, 8);
38/// ```
39pub const SHARED_HEADER_SIZE: usize = 8;
40
41/// Fixed header size of a [`PluginPaintCmd`] in bytes.
42const CMD_SIZE: usize = std::mem::size_of::<PluginPaintCmd>();
43
44/// A raw paint command produced by a WebAssembly plugin.
45///
46/// The record is laid out exactly as the guest writes it into shared memory so
47/// that the host can read it directly from the ring buffer without copying.
48///
49/// # Examples
50///
51/// ```
52/// use martensite_plugin::PluginPaintCmd;
53///
54/// let cmd = PluginPaintCmd {
55///     cmd_type: 1,
56///     flags: 0,
57///     data_len: 12,
58///     payload_offset: 100,
59/// };
60///
61/// assert_eq!(cmd.data_len, 12);
62/// ```
63#[repr(C)]
64#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
65pub struct PluginPaintCmd {
66    /// Discriminant of the paint operation (e.g. 0=DrawLine, 1=FillRect).
67    pub cmd_type: u16,
68    /// Command flags for future extensions.
69    pub flags: u16,
70    /// Length of the variable-length payload in bytes.
71    pub data_len: u32,
72    /// Byte offset of the payload relative to the start of the ring buffer.
73    pub payload_offset: u32,
74}
75
76impl PluginPaintCmd {
77    /// Returns the number of bytes occupied by the command header.
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use martensite_plugin::PluginPaintCmd;
83    ///
84    /// assert_eq!(PluginPaintCmd::header_size(), 12);
85    /// ```
86    #[inline]
87    pub const fn header_size() -> usize {
88        CMD_SIZE
89    }
90
91    /// Writes the command into the supplied byte slice in little-endian order.
92    ///
93    /// Returns `None` if `buf` is too short.
94    fn write_to(&self, buf: &mut [u8]) -> Option<()> {
95        if buf.len() < CMD_SIZE {
96            return None;
97        }
98        let (cmd_type, rest) = buf.split_at_mut(2);
99        cmd_type.copy_from_slice(&self.cmd_type.to_le_bytes());
100        let (flags, rest) = rest.split_at_mut(2);
101        flags.copy_from_slice(&self.flags.to_le_bytes());
102        let (data_len, rest) = rest.split_at_mut(4);
103        data_len.copy_from_slice(&self.data_len.to_le_bytes());
104        let (payload_offset, _) = rest.split_at_mut(4);
105        payload_offset.copy_from_slice(&self.payload_offset.to_le_bytes());
106        Some(())
107    }
108
109    /// Reads a command from the supplied byte slice in little-endian order.
110    ///
111    /// Returns `None` if the slice is too short.
112    fn read_from(buf: &[u8]) -> Option<Self> {
113        if buf.len() < CMD_SIZE {
114            return None;
115        }
116        let cmd_type = u16::from_le_bytes([buf[0], buf[1]]);
117        let flags = u16::from_le_bytes([buf[2], buf[3]]);
118        let data_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
119        let payload_offset = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
120        Some(Self {
121            cmd_type,
122            flags,
123            data_len,
124            payload_offset,
125        })
126    }
127}
128
129/// Errors that can occur while writing into a [`PluginRingBuffer`].
130///
131/// # Examples
132///
133/// ```
134/// use martensite_plugin::RingBufferError;
135///
136/// let err = RingBufferError::BufferFull;
137/// assert_eq!(err.to_string(), "ring buffer is full");
138/// ```
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum RingBufferError {
141    /// The ring buffer does not have enough contiguous free space for the
142    /// command and its payload.
143    BufferFull,
144    /// The command's `data_len` field does not match the supplied payload.
145    PayloadLengthMismatch,
146    /// The command's `payload_offset` or `data_len` is outside the buffer.
147    InvalidPayloadOffset,
148}
149
150impl fmt::Display for RingBufferError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            RingBufferError::BufferFull => write!(f, "ring buffer is full"),
154            RingBufferError::PayloadLengthMismatch => {
155                write!(f, "command data_len does not match payload length")
156            }
157            RingBufferError::InvalidPayloadOffset => {
158                write!(f, "command payload offset or length is out of bounds")
159            }
160        }
161    }
162}
163
164impl std::error::Error for RingBufferError {}
165
166/// A circular command buffer backed by a shared linear memory slice.
167///
168/// The buffer is intentionally not thread-safe; it is intended for single-
169/// producer/single-consumer use between the plugin guest and the host render
170/// thread. All hot-path reads return borrowed slices and perform no allocation.
171///
172/// # Examples
173///
174/// ```
175/// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
176///
177/// let mut backing = vec![0u8; DEFAULT_CAPACITY];
178/// let mut rb = PluginRingBuffer::new(&mut backing);
179///
180/// let cmd = PluginPaintCmd {
181///     cmd_type: 1,
182///     flags: 0,
183///     data_len: 4,
184///     payload_offset: 0,
185/// };
186/// rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
187///
188/// let (read_cmd, payload) = rb.consume().unwrap();
189/// assert_eq!(read_cmd.cmd_type, 1);
190/// assert_eq!(payload, &[1, 2, 3, 4]);
191/// ```
192pub struct PluginRingBuffer<'a> {
193    data: &'a mut [u8],
194    /// Optional leading cursor block when the buffer lives in shared memory.
195    ///
196    /// When present, `head` and `tail` are mirrored into this 8-byte
197    /// little-endian header after every mutation so the other side of the
198    /// shared region can observe the cursors without a host call.
199    header: Option<&'a mut [u8]>,
200    head: u32,
201    tail: u32,
202}
203
204impl<'a> PluginRingBuffer<'a> {
205    /// Creates a ring buffer over the supplied shared memory slice.
206    ///
207    /// The slice must be large enough for at least one command header plus a
208    /// small payload. The buffer starts empty and the cursors are held only
209    /// in this struct; use [`PluginRingBuffer::new_shared`] when the slice is
210    /// a region shared with another party (e.g. guest linear memory).
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use martensite_plugin::{PluginRingBuffer, DEFAULT_CAPACITY};
216    ///
217    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
218    /// let rb = PluginRingBuffer::new(&mut backing);
219    /// assert!(rb.is_empty());
220    /// ```
221    pub fn new(data: &'a mut [u8]) -> Self {
222        Self {
223            data,
224            header: None,
225            head: 0,
226            tail: 0,
227        }
228    }
229
230    /// Creates a ring buffer over a shared memory region with a persisted
231    /// cursor header.
232    ///
233    /// The first [`SHARED_HEADER_SIZE`] bytes of `data` are interpreted as the
234    /// `head`/`tail` cursor block (little-endian `u32` each); the rest is the
235    /// circular payload area. Cursors are read on construction and written
236    /// back after every [`produce`](Self::produce)/[`consume`](Self::consume)
237    /// so a peer sharing the same region sees consistent state. Corrupt
238    /// out-of-range cursors reset the buffer to empty.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use martensite_plugin::ring_buffer::SHARED_HEADER_SIZE;
244    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer};
245    ///
246    /// // 8-byte cursor header + 64 bytes of payload area.
247    /// let mut region = vec![0u8; SHARED_HEADER_SIZE + 64];
248    /// let mut rb = PluginRingBuffer::new_shared(&mut region);
249    /// let cmd = PluginPaintCmd {
250    ///     cmd_type: 1,
251    ///     flags: 0,
252    ///     data_len: 4,
253    ///     payload_offset: 0,
254    /// };
255    /// rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
256    /// // The peer can observe `tail` in the header.
257    /// assert_eq!(u32::from_le_bytes(region[4..8].try_into().unwrap()), 16);
258    /// ```
259    pub fn new_shared(data: &'a mut [u8]) -> Self {
260        if data.len() < SHARED_HEADER_SIZE {
261            return Self::new(data);
262        }
263        let (header, payload) = data.split_at_mut(SHARED_HEADER_SIZE);
264        let head = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
265        let tail = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
266        let capacity = payload.len() as u32;
267        // Reject corrupt cursors; an empty buffer is the safe fallback.
268        let (head, tail) = if head <= capacity && tail <= capacity {
269            (head, tail)
270        } else {
271            (0, 0)
272        };
273        let mut rb = Self {
274            data: payload,
275            header: Some(header),
276            head,
277            tail,
278        };
279        rb.sync_header();
280        rb
281    }
282
283    /// Mirrors the in-struct cursors into the shared header, if present.
284    fn sync_header(&mut self) {
285        if let Some(header) = self.header.as_deref_mut() {
286            header[0..4].copy_from_slice(&self.head.to_le_bytes());
287            header[4..8].copy_from_slice(&self.tail.to_le_bytes());
288        }
289    }
290
291    /// Returns the total capacity of the buffer in bytes.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// use martensite_plugin::{PluginRingBuffer, DEFAULT_CAPACITY};
297    ///
298    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
299    /// let rb = PluginRingBuffer::new(&mut backing);
300    /// assert_eq!(rb.capacity(), DEFAULT_CAPACITY);
301    /// ```
302    pub fn capacity(&self) -> usize {
303        self.data.len()
304    }
305
306    fn capacity_u32(&self) -> u32 {
307        self.data.len() as u32
308    }
309
310    /// Returns the number of bytes currently stored in the buffer.
311    ///
312    /// # Examples
313    ///
314    /// ```
315    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
316    ///
317    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
318    /// let mut rb = PluginRingBuffer::new(&mut backing);
319    /// assert_eq!(rb.len(), 0);
320    ///
321    /// let cmd = PluginPaintCmd { cmd_type: 1, flags: 0, data_len: 0, payload_offset: 0 };
322    /// rb.produce(&cmd, &[]).unwrap();
323    /// assert_eq!(rb.len(), PluginPaintCmd::header_size());
324    /// ```
325    pub fn len(&self) -> usize {
326        let cap = self.capacity_u32();
327        if self.head == self.tail {
328            0
329        } else if self.tail > self.head {
330            (self.tail - self.head) as usize
331        } else {
332            (cap - self.head + self.tail) as usize
333        }
334    }
335
336    /// Returns `true` if the buffer contains no commands.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use martensite_plugin::{PluginRingBuffer, DEFAULT_CAPACITY};
342    ///
343    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
344    /// let rb = PluginRingBuffer::new(&mut backing);
345    /// assert!(rb.is_empty());
346    /// ```
347    pub fn is_empty(&self) -> bool {
348        self.head == self.tail
349    }
350
351    /// Returns the total byte size of a record containing `payload_len` bytes.
352    #[inline]
353    const fn record_size(payload_len: usize) -> usize {
354        CMD_SIZE.saturating_add(payload_len)
355    }
356
357    /// Writes a command and its payload into the ring buffer.
358    ///
359    /// The `payload_offset` field of `cmd` is ignored; it is overwritten with
360    /// the actual offset of the payload in the buffer. `data_len` must match
361    /// `payload.len()`. Records are never split across the buffer boundary.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`RingBufferError::PayloadLengthMismatch`] if `cmd.data_len` does
366    /// not equal `payload.len()`, or [`RingBufferError::BufferFull`] if the
367    /// command does not fit.
368    ///
369    /// # Examples
370    ///
371    /// ```
372    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
373    ///
374    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
375    /// let mut rb = PluginRingBuffer::new(&mut backing);
376    ///
377    /// let cmd = PluginPaintCmd {
378    ///     cmd_type: 2,
379    ///     flags: 0,
380    ///     data_len: 6,
381    ///     payload_offset: 0,
382    /// };
383    /// rb.produce(&cmd, &[9; 6]).unwrap();
384    /// ```
385    pub fn produce(&mut self, cmd: &PluginPaintCmd, payload: &[u8]) -> Result<(), RingBufferError> {
386        if cmd.data_len as usize != payload.len() {
387            return Err(RingBufferError::PayloadLengthMismatch);
388        }
389        let payload_len = payload.len();
390        let total = Self::record_size(payload_len);
391        if total == 0 || self.capacity() == 0 {
392            return Err(RingBufferError::BufferFull);
393        }
394        // Keep one byte of slack so that head == tail always means "empty".
395        if self.len().saturating_add(total) >= self.capacity() {
396            return Err(RingBufferError::BufferFull);
397        }
398
399        let cap_u32 = self.capacity_u32();
400        let tail = self.tail as usize;
401
402        // Decide where to write the new record. Records are never split across
403        // the end of the buffer; if there is not enough contiguous space at the
404        // tail, wrap to the start of the buffer and discard the trailing slack.
405        // The consumer `head` is left unchanged so any unconsumed records at
406        // the end of the buffer are consumed before the newly-wrapped record.
407        // If the buffer is empty before the wrap, the consumer can safely start
408        // from zero.
409        let (write_pos, wrapped) = if self.tail < self.head {
410            if tail.saturating_add(total) > self.head as usize {
411                return Err(RingBufferError::BufferFull);
412            }
413            (tail, false)
414        } else if tail.saturating_add(total) <= self.capacity() {
415            (tail, false)
416        } else if total < self.head as usize {
417            (0, true)
418        } else {
419            return Err(RingBufferError::BufferFull);
420        };
421
422        if wrapped && self.tail == self.head {
423            self.head = 0;
424        }
425
426        let payload_offset = write_pos + CMD_SIZE;
427        let mut stored_cmd = *cmd;
428        stored_cmd.payload_offset = payload_offset as u32;
429        stored_cmd
430            .write_to(&mut self.data[write_pos..])
431            .ok_or(RingBufferError::BufferFull)?;
432        self.data[payload_offset..payload_offset.saturating_add(payload_len)]
433            .copy_from_slice(payload);
434
435        self.tail = (write_pos + total) as u32;
436        if self.tail >= cap_u32 {
437            self.tail = 0;
438        }
439        self.sync_header();
440        Ok(())
441    }
442
443    /// Reads and removes the next command from the ring buffer.
444    ///
445    /// Returns `None` when the buffer is empty or the next record is malformed.
446    /// The returned payload is a borrowed view into the underlying shared
447    /// memory, so no allocation occurs on the readback hot path.
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
453    ///
454    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
455    /// let mut rb = PluginRingBuffer::new(&mut backing);
456    ///
457    /// let cmd = PluginPaintCmd {
458    ///     cmd_type: 0,
459    ///     flags: 0,
460    ///     data_len: 0,
461    ///     payload_offset: 0,
462    /// };
463    /// rb.produce(&cmd, &[]).unwrap();
464    ///
465    /// let (read_cmd, payload) = rb.consume().unwrap();
466    /// assert_eq!(payload.len(), 0);
467    /// assert_eq!(read_cmd.cmd_type, 0);
468    /// ```
469    pub fn consume(&mut self) -> Option<(PluginPaintCmd, &[u8])> {
470        if self.is_empty() {
471            return None;
472        }
473
474        let cap_u32 = self.capacity_u32();
475        if self.head >= cap_u32 {
476            self.head = 0;
477            if self.is_empty() {
478                self.sync_header();
479                return None;
480            }
481        }
482
483        let pos = self.head as usize;
484        if pos.saturating_add(CMD_SIZE) > self.data.len() {
485            // Head points into the slack created by a previous wrap-around.
486            self.head = 0;
487            return self.consume();
488        }
489
490        let cmd = match PluginPaintCmd::read_from(&self.data[pos..]) {
491            Some(cmd) => cmd,
492            None => {
493                // Fatal corruption: the header could not be parsed. Reset the
494                // buffer so the consumer does not wedge on the same bad record
495                // forever.
496                error!(
497                    head = self.head,
498                    tail = self.tail,
499                    pos,
500                    "ring buffer: malformed command header; resetting cursors"
501                );
502                self.head = 0;
503                self.tail = 0;
504                self.sync_header();
505                return None;
506            }
507        };
508        let payload_len = cmd.data_len as usize;
509        let expected_payload_start = pos.saturating_add(CMD_SIZE);
510        let expected_payload_end = expected_payload_start.saturating_add(payload_len);
511
512        // The guest is untrusted; reject any record whose payload is not
513        // contiguously after the header within the buffer. A malformed record
514        // is treated as fatal corruption: resetting the cursors prevents the
515        // consumer from spinning forever on the same record.
516        if cmd.payload_offset as usize != expected_payload_start
517            || expected_payload_end > self.data.len()
518            || expected_payload_end < expected_payload_start
519        {
520            error!(
521                head = self.head,
522                tail = self.tail,
523                pos,
524                payload_offset = cmd.payload_offset,
525                data_len = cmd.data_len,
526                expected_payload_start,
527                expected_payload_end,
528                buf_len = self.data.len(),
529                "ring buffer: malformed record (payload not contiguous); resetting cursors"
530            );
531            self.head = 0;
532            self.tail = 0;
533            self.sync_header();
534            return None;
535        }
536
537        self.head = (expected_payload_end) as u32;
538        if self.head >= cap_u32 {
539            self.head = 0;
540        }
541        // Write the cursors back before borrowing `data` for the payload.
542        if let Some(header) = self.header.as_deref_mut() {
543            header[0..4].copy_from_slice(&self.head.to_le_bytes());
544            header[4..8].copy_from_slice(&self.tail.to_le_bytes());
545        }
546        let payload = &self.data[expected_payload_start..expected_payload_end];
547        Some((cmd, payload))
548    }
549
550    /// Drains all currently available commands from the buffer, invoking the
551    /// provided closure for each command and its borrowed payload.
552    ///
553    /// This is the preferred host readback API because it keeps all reads
554    /// zero-allocation and bounded.
555    ///
556    /// # Examples
557    ///
558    /// ```
559    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
560    ///
561    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
562    /// let mut rb = PluginRingBuffer::new(&mut backing);
563    ///
564    /// let cmd = PluginPaintCmd {
565    ///     cmd_type: 1,
566    ///     flags: 0,
567    ///     data_len: 2,
568    ///     payload_offset: 0,
569    /// };
570    /// rb.produce(&cmd, &[10, 20]).unwrap();
571    /// rb.produce(&cmd, &[30, 40]).unwrap();
572    ///
573    /// let mut count = 0;
574    /// rb.drain(|_cmd, payload| {
575    ///     count += 1;
576    ///     assert_eq!(payload.len(), 2);
577    /// });
578    /// assert_eq!(count, 2);
579    /// ```
580    pub fn drain<F>(&mut self, mut f: F)
581    where
582        F: FnMut(&PluginPaintCmd, &[u8]),
583    {
584        while let Some((cmd, payload)) = self.consume() {
585            f(&cmd, payload);
586        }
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn empty_buffer_returns_none() {
596        let mut data = vec![0u8; DEFAULT_CAPACITY];
597        let mut rb = PluginRingBuffer::new(&mut data);
598        assert!(rb.is_empty());
599        assert_eq!(rb.len(), 0);
600        assert!(rb.consume().is_none());
601    }
602
603    #[test]
604    fn produce_and_consume_single_command() {
605        let mut data = vec![0u8; DEFAULT_CAPACITY];
606        let mut rb = PluginRingBuffer::new(&mut data);
607        let cmd = PluginPaintCmd {
608            cmd_type: 1,
609            flags: 0,
610            data_len: 4,
611            payload_offset: 0,
612        };
613        rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
614        assert_eq!(rb.len(), CMD_SIZE + 4);
615
616        let (read_cmd, payload) = rb.consume().unwrap();
617        assert_eq!(read_cmd.cmd_type, 1);
618        assert_eq!(read_cmd.data_len, 4);
619        assert_eq!(payload, &[1, 2, 3, 4]);
620        assert!(rb.is_empty());
621    }
622
623    #[test]
624    fn payload_length_mismatch_is_rejected() {
625        let mut data = vec![0u8; DEFAULT_CAPACITY];
626        let mut rb = PluginRingBuffer::new(&mut data);
627        let cmd = PluginPaintCmd {
628            cmd_type: 1,
629            flags: 0,
630            data_len: 10,
631            payload_offset: 0,
632        };
633        assert_eq!(
634            rb.produce(&cmd, &[1, 2, 3, 4]),
635            Err(RingBufferError::PayloadLengthMismatch)
636        );
637    }
638
639    #[test]
640    fn wrap_around_reuses_start_of_buffer() {
641        let mut data = vec![0u8; 64];
642        let mut rb = PluginRingBuffer::new(&mut data);
643
644        // Fill most of the buffer.
645        let cmd = PluginPaintCmd {
646            cmd_type: 2,
647            flags: 0,
648            data_len: 40,
649            payload_offset: 0,
650        };
651        rb.produce(&cmd, &[7; 40]).unwrap();
652        rb.consume().unwrap();
653
654        // A new command that does not fit at the old tail should wrap to the
655        // start of the buffer.
656        let cmd2 = PluginPaintCmd {
657            cmd_type: 3,
658            flags: 0,
659            data_len: 16,
660            payload_offset: 0,
661        };
662        rb.produce(&cmd2, &[8; 16]).unwrap();
663
664        let (read_cmd, payload) = rb.consume().unwrap();
665        assert_eq!(read_cmd.cmd_type, 3);
666        assert_eq!(payload, &[8; 16]);
667        assert!(rb.is_empty());
668    }
669
670    #[test]
671    fn buffer_full_is_reported() {
672        let mut data = vec![0u8; 64];
673        let mut rb = PluginRingBuffer::new(&mut data);
674
675        let cmd = PluginPaintCmd {
676            cmd_type: 1,
677            flags: 0,
678            data_len: 40,
679            payload_offset: 0,
680        };
681        rb.produce(&cmd, &[1; 40]).unwrap();
682        assert_eq!(rb.produce(&cmd, &[1; 40]), Err(RingBufferError::BufferFull));
683    }
684
685    #[test]
686    fn drain_visits_all_commands() {
687        let mut data = vec![0u8; DEFAULT_CAPACITY];
688        let mut rb = PluginRingBuffer::new(&mut data);
689
690        let cmd = PluginPaintCmd {
691            cmd_type: 1,
692            flags: 0,
693            data_len: 2,
694            payload_offset: 0,
695        };
696        for i in 0u8..5 {
697            rb.produce(&cmd, &[i, i + 1]).unwrap();
698        }
699
700        let mut count = 0;
701        rb.drain(|_cmd, payload| {
702            assert_eq!(payload.len(), 2);
703            count += 1;
704        });
705        assert_eq!(count, 5);
706        assert!(rb.is_empty());
707    }
708
709    #[test]
710    fn cmd_serialization_roundtrips() {
711        let original = PluginPaintCmd {
712            cmd_type: 0xABCD,
713            flags: 0x1234,
714            data_len: 0xDEAD_BEEF,
715            payload_offset: 0xCAFE_BABE,
716        };
717        let mut buf = [0u8; CMD_SIZE];
718        original.write_to(&mut buf).unwrap();
719        let parsed = PluginPaintCmd::read_from(&buf).unwrap();
720        assert_eq!(original, parsed);
721    }
722
723    #[test]
724    fn zero_payload_command_roundtrips() {
725        let mut data = vec![0u8; 64];
726        let mut rb = PluginRingBuffer::new(&mut data);
727
728        let cmd = PluginPaintCmd {
729            cmd_type: 0,
730            flags: 0,
731            data_len: 0,
732            payload_offset: 0,
733        };
734        rb.produce(&cmd, &[]).unwrap();
735        let (read_cmd, payload) = rb.consume().unwrap();
736        assert_eq!(read_cmd.cmd_type, cmd.cmd_type);
737        assert_eq!(read_cmd.flags, cmd.flags);
738        assert_eq!(read_cmd.data_len, cmd.data_len);
739        assert!(payload.is_empty());
740    }
741
742    #[test]
743    fn malformed_record_resets_cursors_and_does_not_wedge() {
744        let mut data = vec![0u8; 64];
745
746        // Manually craft a record whose payload_offset does not match the
747        // expected position right after the header. Write it directly into the
748        // backing store before handing the slice to the ring buffer.
749        let bad = PluginPaintCmd {
750            cmd_type: 1,
751            flags: 0,
752            data_len: 4,
753            payload_offset: 0, // wrong: should be CMD_SIZE
754        };
755        bad.write_to(&mut data[0..]).unwrap();
756        // Fill the payload area with non-zero bytes so the record looks
757        // populated.
758        data[CMD_SIZE..CMD_SIZE + 4].fill(9);
759
760        let mut rb = PluginRingBuffer::new(&mut data);
761        rb.head = 0;
762        rb.tail = (CMD_SIZE + 4) as u32;
763
764        // First call detects corruption and returns None.
765        assert!(rb.consume().is_none());
766        // Cursors were reset, so the buffer is now empty and subsequent calls
767        // do not wedge on the same bad record.
768        assert!(rb.is_empty());
769        assert!(rb.consume().is_none());
770
771        // The buffer is usable again after the reset.
772        let cmd = PluginPaintCmd {
773            cmd_type: 2,
774            flags: 0,
775            data_len: 2,
776            payload_offset: 0,
777        };
778        rb.produce(&cmd, &[3, 4]).unwrap();
779        let (read_cmd, payload) = rb.consume().unwrap();
780        assert_eq!(read_cmd.cmd_type, 2);
781        assert_eq!(payload, &[3, 4]);
782    }
783
784    #[test]
785    fn malformed_record_resets_shared_header() {
786        use crate::ring_buffer::SHARED_HEADER_SIZE;
787        let mut region = vec![0u8; SHARED_HEADER_SIZE + 64];
788
789        // Write a valid record, then corrupt its payload_offset so the next
790        // consume treats it as malformed. The corruption is applied directly to
791        // the backing region before the ring buffer borrows it.
792        {
793            let mut rb = PluginRingBuffer::new_shared(&mut region);
794            let cmd = PluginPaintCmd {
795                cmd_type: 1,
796                flags: 0,
797                data_len: 4,
798                payload_offset: 0,
799            };
800            rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
801        }
802        // Corrupt the payload_offset field (bytes 8..12 of the payload area,
803        // i.e. region offset SHARED_HEADER_SIZE + 8).
804        let off = SHARED_HEADER_SIZE + 8;
805        region[off..off + 4].copy_from_slice(&99u32.to_le_bytes());
806
807        {
808            let mut rb = PluginRingBuffer::new_shared(&mut region);
809            assert!(rb.consume().is_none());
810        }
811        // The shared header must reflect the reset cursors (head == tail == 0).
812        let head = u32::from_le_bytes(region[0..4].try_into().unwrap());
813        let tail = u32::from_le_bytes(region[4..8].try_into().unwrap());
814        assert_eq!(head, 0);
815        assert_eq!(tail, 0);
816    }
817}