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
10/// Default size of the shared linear memory ring buffer (256 KiB).
11pub const DEFAULT_CAPACITY: usize = 256 * 1024;
12
13/// Fixed header size of a [`PluginPaintCmd`] in bytes.
14const CMD_SIZE: usize = std::mem::size_of::<PluginPaintCmd>();
15
16/// A raw paint command produced by a WebAssembly plugin.
17///
18/// The record is laid out exactly as the guest writes it into shared memory so
19/// that the host can read it directly from the ring buffer without copying.
20///
21/// # Examples
22///
23/// ```
24/// use martensite_plugin::PluginPaintCmd;
25///
26/// let cmd = PluginPaintCmd {
27///     cmd_type: 1,
28///     flags: 0,
29///     data_len: 12,
30///     payload_offset: 100,
31/// };
32///
33/// assert_eq!(cmd.data_len, 12);
34/// ```
35#[repr(C)]
36#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
37pub struct PluginPaintCmd {
38    /// Discriminant of the paint operation (e.g. 0=DrawLine, 1=FillRect).
39    pub cmd_type: u16,
40    /// Command flags for future extensions.
41    pub flags: u16,
42    /// Length of the variable-length payload in bytes.
43    pub data_len: u32,
44    /// Byte offset of the payload relative to the start of the ring buffer.
45    pub payload_offset: u32,
46}
47
48impl PluginPaintCmd {
49    /// Returns the number of bytes occupied by the command header.
50    #[inline]
51    pub const fn header_size() -> usize {
52        CMD_SIZE
53    }
54
55    /// Writes the command into the supplied byte slice in little-endian order.
56    ///
57    /// Returns `None` if `buf` is too short.
58    fn write_to(&self, buf: &mut [u8]) -> Option<()> {
59        if buf.len() < CMD_SIZE {
60            return None;
61        }
62        let (cmd_type, rest) = buf.split_at_mut(2);
63        cmd_type.copy_from_slice(&self.cmd_type.to_le_bytes());
64        let (flags, rest) = rest.split_at_mut(2);
65        flags.copy_from_slice(&self.flags.to_le_bytes());
66        let (data_len, rest) = rest.split_at_mut(4);
67        data_len.copy_from_slice(&self.data_len.to_le_bytes());
68        let (payload_offset, _) = rest.split_at_mut(4);
69        payload_offset.copy_from_slice(&self.payload_offset.to_le_bytes());
70        Some(())
71    }
72
73    /// Reads a command from the supplied byte slice in little-endian order.
74    ///
75    /// Returns `None` if the slice is too short.
76    fn read_from(buf: &[u8]) -> Option<Self> {
77        if buf.len() < CMD_SIZE {
78            return None;
79        }
80        let cmd_type = u16::from_le_bytes([buf[0], buf[1]]);
81        let flags = u16::from_le_bytes([buf[2], buf[3]]);
82        let data_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
83        let payload_offset = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
84        Some(Self {
85            cmd_type,
86            flags,
87            data_len,
88            payload_offset,
89        })
90    }
91}
92
93/// Errors that can occur while writing into a [`PluginRingBuffer`].
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum RingBufferError {
96    /// The ring buffer does not have enough contiguous free space for the
97    /// command and its payload.
98    BufferFull,
99    /// The command's `data_len` field does not match the supplied payload.
100    PayloadLengthMismatch,
101    /// The command's `payload_offset` or `data_len` is outside the buffer.
102    InvalidPayloadOffset,
103}
104
105impl fmt::Display for RingBufferError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            RingBufferError::BufferFull => write!(f, "ring buffer is full"),
109            RingBufferError::PayloadLengthMismatch => {
110                write!(f, "command data_len does not match payload length")
111            }
112            RingBufferError::InvalidPayloadOffset => {
113                write!(f, "command payload offset or length is out of bounds")
114            }
115        }
116    }
117}
118
119impl std::error::Error for RingBufferError {}
120
121/// A circular command buffer backed by a shared linear memory slice.
122///
123/// The buffer is intentionally not thread-safe; it is intended for single-
124/// producer/single-consumer use between the plugin guest and the host render
125/// thread. All hot-path reads return borrowed slices and perform no allocation.
126///
127/// # Examples
128///
129/// ```
130/// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
131///
132/// let mut backing = vec![0u8; DEFAULT_CAPACITY];
133/// let mut rb = PluginRingBuffer::new(&mut backing);
134///
135/// let cmd = PluginPaintCmd {
136///     cmd_type: 1,
137///     flags: 0,
138///     data_len: 4,
139///     payload_offset: 0,
140/// };
141/// rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
142///
143/// let (read_cmd, payload) = rb.consume().unwrap();
144/// assert_eq!(read_cmd.cmd_type, 1);
145/// assert_eq!(payload, &[1, 2, 3, 4]);
146/// ```
147pub struct PluginRingBuffer<'a> {
148    data: &'a mut [u8],
149    head: u32,
150    tail: u32,
151}
152
153impl<'a> PluginRingBuffer<'a> {
154    /// Creates a ring buffer over the supplied shared memory slice.
155    ///
156    /// The slice must be large enough for at least one command header plus a
157    /// small payload. The buffer starts empty.
158    pub fn new(data: &'a mut [u8]) -> Self {
159        Self {
160            data,
161            head: 0,
162            tail: 0,
163        }
164    }
165
166    /// Returns the total capacity of the buffer in bytes.
167    pub fn capacity(&self) -> usize {
168        self.data.len()
169    }
170
171    fn capacity_u32(&self) -> u32 {
172        self.data.len() as u32
173    }
174
175    /// Returns the number of bytes currently stored in the buffer.
176    pub fn len(&self) -> usize {
177        let cap = self.capacity_u32();
178        if self.head == self.tail {
179            0
180        } else if self.tail > self.head {
181            (self.tail - self.head) as usize
182        } else {
183            (cap - self.head + self.tail) as usize
184        }
185    }
186
187    /// Returns `true` if the buffer contains no commands.
188    pub fn is_empty(&self) -> bool {
189        self.head == self.tail
190    }
191
192    /// Returns the total byte size of a record containing `payload_len` bytes.
193    #[inline]
194    const fn record_size(payload_len: usize) -> usize {
195        CMD_SIZE.saturating_add(payload_len)
196    }
197
198    /// Writes a command and its payload into the ring buffer.
199    ///
200    /// The `payload_offset` field of `cmd` is ignored; it is overwritten with
201    /// the actual offset of the payload in the buffer. `data_len` must match
202    /// `payload.len()`. Records are never split across the buffer boundary.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`RingBufferError::PayloadLengthMismatch`] if `cmd.data_len` does
207    /// not equal `payload.len()`, or [`RingBufferError::BufferFull`] if the
208    /// command does not fit.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
214    ///
215    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
216    /// let mut rb = PluginRingBuffer::new(&mut backing);
217    ///
218    /// let cmd = PluginPaintCmd {
219    ///     cmd_type: 2,
220    ///     flags: 0,
221    ///     data_len: 6,
222    ///     payload_offset: 0,
223    /// };
224    /// rb.produce(&cmd, &[9; 6]).unwrap();
225    /// ```
226    pub fn produce(&mut self, cmd: &PluginPaintCmd, payload: &[u8]) -> Result<(), RingBufferError> {
227        if cmd.data_len as usize != payload.len() {
228            return Err(RingBufferError::PayloadLengthMismatch);
229        }
230        let payload_len = payload.len();
231        let total = Self::record_size(payload_len);
232        if total == 0 || self.capacity() == 0 {
233            return Err(RingBufferError::BufferFull);
234        }
235        // Keep one byte of slack so that head == tail always means "empty".
236        if self.len().saturating_add(total) >= self.capacity() {
237            return Err(RingBufferError::BufferFull);
238        }
239
240        let cap_u32 = self.capacity_u32();
241        let tail = self.tail as usize;
242
243        // Decide where to write the new record. Records are never split across
244        // the end of the buffer; if there is not enough contiguous space at the
245        // tail, wrap to the start of the buffer and discard the trailing slack.
246        // The consumer `head` is left unchanged so any unconsumed records at
247        // the end of the buffer are consumed before the newly-wrapped record.
248        // If the buffer is empty before the wrap, the consumer can safely start
249        // from zero.
250        let (write_pos, wrapped) = if self.tail < self.head {
251            if tail.saturating_add(total) > self.head as usize {
252                return Err(RingBufferError::BufferFull);
253            }
254            (tail, false)
255        } else if tail.saturating_add(total) <= self.capacity() {
256            (tail, false)
257        } else if total < self.head as usize {
258            (0, true)
259        } else {
260            return Err(RingBufferError::BufferFull);
261        };
262
263        if wrapped && self.tail == self.head {
264            self.head = 0;
265        }
266
267        let payload_offset = write_pos + CMD_SIZE;
268        let mut stored_cmd = *cmd;
269        stored_cmd.payload_offset = payload_offset as u32;
270        stored_cmd
271            .write_to(&mut self.data[write_pos..])
272            .ok_or(RingBufferError::BufferFull)?;
273        self.data[payload_offset..payload_offset.saturating_add(payload_len)]
274            .copy_from_slice(payload);
275
276        self.tail = (write_pos + total) as u32;
277        if self.tail >= cap_u32 {
278            self.tail = 0;
279        }
280        Ok(())
281    }
282
283    /// Reads and removes the next command from the ring buffer.
284    ///
285    /// Returns `None` when the buffer is empty or the next record is malformed.
286    /// The returned payload is a borrowed view into the underlying shared
287    /// memory, so no allocation occurs on the readback hot path.
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
293    ///
294    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
295    /// let mut rb = PluginRingBuffer::new(&mut backing);
296    ///
297    /// let cmd = PluginPaintCmd {
298    ///     cmd_type: 0,
299    ///     flags: 0,
300    ///     data_len: 0,
301    ///     payload_offset: 0,
302    /// };
303    /// rb.produce(&cmd, &[]).unwrap();
304    ///
305    /// let (read_cmd, payload) = rb.consume().unwrap();
306    /// assert_eq!(payload.len(), 0);
307    /// assert_eq!(read_cmd.cmd_type, 0);
308    /// ```
309    pub fn consume(&mut self) -> Option<(PluginPaintCmd, &[u8])> {
310        if self.is_empty() {
311            return None;
312        }
313
314        let cap_u32 = self.capacity_u32();
315        if self.head >= cap_u32 {
316            self.head = 0;
317            if self.is_empty() {
318                return None;
319            }
320        }
321
322        let pos = self.head as usize;
323        if pos.saturating_add(CMD_SIZE) > self.data.len() {
324            // Head points into the slack created by a previous wrap-around.
325            self.head = 0;
326            return self.consume();
327        }
328
329        let cmd = PluginPaintCmd::read_from(&self.data[pos..])?;
330        let payload_len = cmd.data_len as usize;
331        let expected_payload_start = pos.saturating_add(CMD_SIZE);
332        let expected_payload_end = expected_payload_start.saturating_add(payload_len);
333
334        // The guest is untrusted; reject any record whose payload is not
335        // contiguously after the header within the buffer.
336        if cmd.payload_offset as usize != expected_payload_start
337            || expected_payload_end > self.data.len()
338            || expected_payload_end < expected_payload_start
339        {
340            return None;
341        }
342
343        let payload = &self.data[expected_payload_start..expected_payload_end];
344        self.head = (expected_payload_end) as u32;
345        if self.head >= cap_u32 {
346            self.head = 0;
347        }
348        Some((cmd, payload))
349    }
350
351    /// Drains all currently available commands from the buffer, invoking the
352    /// provided closure for each command and its borrowed payload.
353    ///
354    /// This is the preferred host readback API because it keeps all reads
355    /// zero-allocation and bounded.
356    ///
357    /// # Examples
358    ///
359    /// ```
360    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
361    ///
362    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
363    /// let mut rb = PluginRingBuffer::new(&mut backing);
364    ///
365    /// let cmd = PluginPaintCmd {
366    ///     cmd_type: 1,
367    ///     flags: 0,
368    ///     data_len: 2,
369    ///     payload_offset: 0,
370    /// };
371    /// rb.produce(&cmd, &[10, 20]).unwrap();
372    /// rb.produce(&cmd, &[30, 40]).unwrap();
373    ///
374    /// let mut count = 0;
375    /// rb.drain(|_cmd, payload| {
376    ///     count += 1;
377    ///     assert_eq!(payload.len(), 2);
378    /// });
379    /// assert_eq!(count, 2);
380    /// ```
381    pub fn drain<F>(&mut self, mut f: F)
382    where
383        F: FnMut(&PluginPaintCmd, &[u8]),
384    {
385        while let Some((cmd, payload)) = self.consume() {
386            f(&cmd, payload);
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn empty_buffer_returns_none() {
397        let mut data = vec![0u8; DEFAULT_CAPACITY];
398        let mut rb = PluginRingBuffer::new(&mut data);
399        assert!(rb.is_empty());
400        assert_eq!(rb.len(), 0);
401        assert!(rb.consume().is_none());
402    }
403
404    #[test]
405    fn produce_and_consume_single_command() {
406        let mut data = vec![0u8; DEFAULT_CAPACITY];
407        let mut rb = PluginRingBuffer::new(&mut data);
408        let cmd = PluginPaintCmd {
409            cmd_type: 1,
410            flags: 0,
411            data_len: 4,
412            payload_offset: 0,
413        };
414        rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
415        assert_eq!(rb.len(), CMD_SIZE + 4);
416
417        let (read_cmd, payload) = rb.consume().unwrap();
418        assert_eq!(read_cmd.cmd_type, 1);
419        assert_eq!(read_cmd.data_len, 4);
420        assert_eq!(payload, &[1, 2, 3, 4]);
421        assert!(rb.is_empty());
422    }
423
424    #[test]
425    fn payload_length_mismatch_is_rejected() {
426        let mut data = vec![0u8; DEFAULT_CAPACITY];
427        let mut rb = PluginRingBuffer::new(&mut data);
428        let cmd = PluginPaintCmd {
429            cmd_type: 1,
430            flags: 0,
431            data_len: 10,
432            payload_offset: 0,
433        };
434        assert_eq!(
435            rb.produce(&cmd, &[1, 2, 3, 4]),
436            Err(RingBufferError::PayloadLengthMismatch)
437        );
438    }
439
440    #[test]
441    fn wrap_around_reuses_start_of_buffer() {
442        let mut data = vec![0u8; 64];
443        let mut rb = PluginRingBuffer::new(&mut data);
444
445        // Fill most of the buffer.
446        let cmd = PluginPaintCmd {
447            cmd_type: 2,
448            flags: 0,
449            data_len: 40,
450            payload_offset: 0,
451        };
452        rb.produce(&cmd, &[7; 40]).unwrap();
453        rb.consume().unwrap();
454
455        // A new command that does not fit at the old tail should wrap to the
456        // start of the buffer.
457        let cmd2 = PluginPaintCmd {
458            cmd_type: 3,
459            flags: 0,
460            data_len: 16,
461            payload_offset: 0,
462        };
463        rb.produce(&cmd2, &[8; 16]).unwrap();
464
465        let (read_cmd, payload) = rb.consume().unwrap();
466        assert_eq!(read_cmd.cmd_type, 3);
467        assert_eq!(payload, &[8; 16]);
468        assert!(rb.is_empty());
469    }
470
471    #[test]
472    fn buffer_full_is_reported() {
473        let mut data = vec![0u8; 64];
474        let mut rb = PluginRingBuffer::new(&mut data);
475
476        let cmd = PluginPaintCmd {
477            cmd_type: 1,
478            flags: 0,
479            data_len: 40,
480            payload_offset: 0,
481        };
482        rb.produce(&cmd, &[1; 40]).unwrap();
483        assert_eq!(rb.produce(&cmd, &[1; 40]), Err(RingBufferError::BufferFull));
484    }
485
486    #[test]
487    fn drain_visits_all_commands() {
488        let mut data = vec![0u8; DEFAULT_CAPACITY];
489        let mut rb = PluginRingBuffer::new(&mut data);
490
491        let cmd = PluginPaintCmd {
492            cmd_type: 1,
493            flags: 0,
494            data_len: 2,
495            payload_offset: 0,
496        };
497        for i in 0u8..5 {
498            rb.produce(&cmd, &[i, i + 1]).unwrap();
499        }
500
501        let mut count = 0;
502        rb.drain(|_cmd, payload| {
503            assert_eq!(payload.len(), 2);
504            count += 1;
505        });
506        assert_eq!(count, 5);
507        assert!(rb.is_empty());
508    }
509
510    #[test]
511    fn cmd_serialization_roundtrips() {
512        let original = PluginPaintCmd {
513            cmd_type: 0xABCD,
514            flags: 0x1234,
515            data_len: 0xDEAD_BEEF,
516            payload_offset: 0xCAFE_BABE,
517        };
518        let mut buf = [0u8; CMD_SIZE];
519        original.write_to(&mut buf).unwrap();
520        let parsed = PluginPaintCmd::read_from(&buf).unwrap();
521        assert_eq!(original, parsed);
522    }
523
524    #[test]
525    fn zero_payload_command_roundtrips() {
526        let mut data = vec![0u8; 64];
527        let mut rb = PluginRingBuffer::new(&mut data);
528
529        let cmd = PluginPaintCmd {
530            cmd_type: 0,
531            flags: 0,
532            data_len: 0,
533            payload_offset: 0,
534        };
535        rb.produce(&cmd, &[]).unwrap();
536        let (read_cmd, payload) = rb.consume().unwrap();
537        assert_eq!(read_cmd.cmd_type, cmd.cmd_type);
538        assert_eq!(read_cmd.flags, cmd.flags);
539        assert_eq!(read_cmd.data_len, cmd.data_len);
540        assert!(payload.is_empty());
541    }
542}