pixel8_runtime/pico8/
clipboard.rs1use super::{hex, hex_bytes, music_from_mem, sfx_from_mem, SFX_MEM_LEN};
10use crate::{
11 assets::{SHEET_H, SHEET_W},
12 clipboard::{tagged, Pasted, PixelRect, SfxClip, Slotted},
13};
14use anyhow::{bail, Result};
15
16pub fn parse_clipboard(text: &str) -> Result<Pasted> {
19 if let Some(inner) = tagged(text, "gfx") {
20 return Ok(Pasted::Sprites {
21 rect: parse_gfx(inner)?,
22 flags: None,
23 });
24 }
25 if let Some(inner) = tagged(text, "sfx") {
26 return Ok(Pasted::Sfx(parse_sfx(inner)?));
27 }
28 bail!("no sprite or sound on the clipboard");
29}
30
31fn parse_gfx(inner: &str) -> Result<PixelRect> {
34 let d: Vec<u8> = inner.trim().chars().map(|c| hex(c).unwrap_or(0)).collect();
35 if d.len() < 4 {
36 bail!("clipboard sprite data is truncated");
37 }
38 let w = (d[0] << 4 | d[1]) as usize;
39 let h = (d[2] << 4 | d[3]) as usize;
40 if w == 0 || h == 0 || w > SHEET_W || h > SHEET_H {
41 bail!("clipboard sprite size {w}x{h} is out of range");
42 }
43 if d.len() < 4 + w * h {
44 bail!("clipboard sprite data is truncated");
45 }
46 Ok(PixelRect {
47 w,
48 h,
49 pixels: d[4..4 + w * h].to_vec(),
50 })
51}
52
53fn parse_sfx(inner: &str) -> Result<SfxClip> {
58 let b = hex_bytes(inner);
59 if b.len() < 2 {
60 bail!("clipboard SFX data is truncated");
61 }
62 let rec = 1 + SFX_MEM_LEN; let count = (b[0] as usize).min(b.len().saturating_sub(2) / rec);
64 if count == 0 && b[2..].len() < 4 {
67 bail!("clipboard SFX data is empty");
68 }
69 let mut records = Vec::with_capacity(count);
70 for i in 0..count {
71 let off = 2 + i * rec;
72 records.push(Slotted {
73 src: b[off],
74 value: sfx_from_mem(&b[off + 1..off + 1 + SFX_MEM_LEN]),
75 });
76 }
77 let patterns = b[2 + count * rec..]
79 .chunks(4)
80 .filter(|c| c.len() == 4)
81 .map(|c| music_from_mem([c[0], c[1], c[2], c[3]]))
82 .collect();
83 Ok(SfxClip { records, patterns })
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 const MUSIC: &str = "[sfx]020108090a0900090a0900090a0900090a0000090a0900090a0900090a0900090\
92 a0900090a0900090a0900090a0900090a0900090a0900090a0900090a0900090a09000008002009090\
93 a0e000e0a0e00100a1000150a1000180a1000130a1000150a1000100a1000130a10000e0a1000100a1\
94 000130a1000090a10000e0a0d00100a0000150a100000080000080943440[/sfx]";
95
96 #[test]
97 fn gfx_header_then_nibbles() {
98 let p = parse_clipboard("[gfx]0202abcd[/gfx]").unwrap();
99 let Pasted::Sprites { rect: r, .. } = p else {
100 panic!("not sprites")
101 };
102 assert_eq!((r.w, r.h), (2, 2));
103 assert_eq!(r.pixels, vec![0xa, 0xb, 0xc, 0xd]);
104 }
105
106 #[test]
107 fn sfx_records_carry_source_slots() {
108 let p = parse_clipboard(MUSIC).unwrap();
109 let Pasted::Sfx(clip) = p else {
110 panic!("not sfx")
111 };
112 assert_eq!(clip.records.len(), 2);
113 assert_eq!(clip.records[0].src, 8);
114 assert_eq!(clip.records[1].src, 9);
115 assert_eq!(clip.patterns.len(), 1);
117 assert_eq!(clip.patterns[0].channels, [Some(8), Some(9), None, None]);
118 }
119
120 #[test]
121 fn unknown_clipboard_is_an_error() {
122 assert!(parse_clipboard("hello world").is_err());
123 }
124
125 #[test]
126 fn footer_only_sfx_blob_decodes_to_an_empty_pattern() {
127 let Pasted::Sfx(clip) = parse_clipboard("[sfx]000140404040[/sfx]").unwrap() else {
128 panic!("not sfx")
129 };
130 assert!(clip.records.is_empty());
131 assert_eq!(clip.patterns.len(), 1);
132 assert_eq!(clip.patterns[0].channels, [None, None, None, None]);
133 }
134}