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}