1use crate::{
8 assets::{
9 Assets, MusicPattern, Sfx, SpriteSheet, MAP_H, MAP_W, MUSIC_COUNT, SFX_COUNT, SHEET_H,
10 SHEET_W, SPRITES_PER_ROW, SPRITE_SIZE,
11 },
12 pico8::{next_free_sfx, remap_custom_instruments, remap_music_channels},
13};
14use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17
18pub struct PixelRect {
20 pub w: usize,
21 pub h: usize,
22 pub pixels: Vec<u8>,
23}
24
25pub struct Slotted<T> {
27 pub src: u8,
28 pub value: T,
29}
30
31pub struct SfxClip {
35 pub records: Vec<Slotted<Sfx>>,
36 pub patterns: Vec<MusicPattern>,
37}
38
39pub enum Pasted {
41 Sprites {
44 rect: PixelRect,
45 flags: Option<Vec<u8>>,
46 },
47 Sfx(SfxClip),
48 Map {
50 w: usize,
51 h: usize,
52 tiles: Vec<u8>,
53 },
54}
55
56pub struct PasteReport {
58 pub summary: String,
60 pub clipped: usize,
62 pub warnings: Vec<String>,
64}
65
66pub fn paste_sprites(
71 sheet: &mut SpriteSheet,
72 rect: &PixelRect,
73 x0: i32,
74 y0: i32,
75 flags: Option<&[u8]>,
76) -> PasteReport {
77 let mut clipped = 0;
78 for ry in 0..rect.h {
79 for rx in 0..rect.w {
80 let (x, y) = (x0 + rx as i32, y0 + ry as i32);
81 if (0..SHEET_W as i32).contains(&x) && (0..SHEET_H as i32).contains(&y) {
82 sheet.set(x, y, rect.pixels[ry * rect.w + rx]);
83 } else {
84 clipped += 1;
85 }
86 }
87 }
88 let slot =
89 (y0.max(0) as usize / SPRITE_SIZE) * SPRITES_PER_ROW + (x0.max(0) as usize / SPRITE_SIZE);
90 if let Some(flags) = flags {
93 let cols = rect.w.div_ceil(SPRITE_SIZE);
94 let sprite_x0 = (x0.max(0) as usize) / SPRITE_SIZE;
95 let sprite_y0 = (y0.max(0) as usize) / SPRITE_SIZE;
96 for (i, &f) in flags.iter().enumerate() {
97 let n = (sprite_y0 + i / cols) * SPRITES_PER_ROW + (sprite_x0 + i % cols);
98 if n < sheet.flags.len() {
99 sheet.flags[n] = f;
100 }
101 }
102 }
103 PasteReport {
104 summary: rect_summary(rect.w, rect.h, clipped, slot),
105 clipped,
106 warnings: Vec::new(),
107 }
108}
109
110pub fn paste_sfx(sfx: &mut [Sfx], records: &[Slotted<Sfx>], at: usize) -> PasteReport {
114 let fit = records.len().min(SFX_COUNT.saturating_sub(at));
115 let mut sfx_map: HashMap<u8, usize> = HashMap::new();
116 for (i, rec) in records.iter().take(fit).enumerate() {
117 sfx[at + i] = rec.value.clone();
118 sfx_map.insert(rec.src, at + i);
119 }
120 let mut warnings = Vec::new();
121 for (i, rec) in records.iter().take(fit).enumerate() {
122 remap_custom_instruments(
123 &mut sfx[at + i].notes,
124 &sfx_map,
125 &format!("SFX {}", rec.src),
126 &mut warnings,
127 );
128 }
129 let clipped = records.len() - fit;
130 PasteReport {
131 summary: seq_summary("SFX", at, fit, clipped, warnings.len()),
132 clipped,
133 warnings,
134 }
135}
136
137pub fn paste_pattern(assets: &mut Assets, clip: &SfxClip, at_pattern: usize) -> PasteReport {
141 let mut warnings = Vec::new();
142
143 let sfx_start = next_free_sfx(&assets.sfx);
144 let sfx_fit = clip.records.len().min(SFX_COUNT.saturating_sub(sfx_start));
145 let mut sfx_map: HashMap<u8, usize> = HashMap::new();
146 for (i, rec) in clip.records.iter().take(sfx_fit).enumerate() {
147 assets.sfx[sfx_start + i] = rec.value.clone();
148 sfx_map.insert(rec.src, sfx_start + i);
149 }
150 for (i, rec) in clip.records.iter().take(sfx_fit).enumerate() {
151 remap_custom_instruments(
152 &mut assets.sfx[sfx_start + i].notes,
153 &sfx_map,
154 &format!("SFX {}", rec.src),
155 &mut warnings,
156 );
157 }
158
159 let pat_fit = clip
160 .patterns
161 .len()
162 .min(MUSIC_COUNT.saturating_sub(at_pattern));
163 for (i, pat) in clip.patterns.iter().take(pat_fit).enumerate() {
164 let mut p = *pat;
165 remap_music_channels(&mut p, &sfx_map, &format!("pattern {i}"), &mut warnings);
166 assets.music[at_pattern + i] = p;
167 }
168
169 let clipped = (clip.records.len() - sfx_fit) + (clip.patterns.len() - pat_fit);
170 let summary = pattern_summary(at_pattern, pat_fit, sfx_fit, clipped, warnings.len());
171 PasteReport {
172 summary,
173 clipped,
174 warnings,
175 }
176}
177
178fn count(n: usize) -> String {
186 if n > 99 {
187 "99+".to_string()
188 } else {
189 n.to_string()
190 }
191}
192
193fn rect_summary(w: usize, h: usize, clipped: usize, slot: usize) -> String {
195 let mut s = format!("pasted {w}x{h} spr {slot}");
196 if clipped > 0 {
197 s.push_str(&format!(" {}cut", count(clipped)));
198 }
199 s
200}
201
202fn seq_summary(kind: &str, at: usize, fit: usize, clipped: usize, warns: usize) -> String {
205 let mut s = match fit {
206 0 => return format!("no free {kind} slot"),
207 1 => format!("pasted {kind} {at}"),
208 n => format!("pasted {kind} {at}-{}", at + n - 1),
209 };
210 if clipped > 0 {
211 s.push_str(&format!(" {}cut", count(clipped)));
212 }
213 if warns > 0 {
214 s.push_str(&format!(" {}warn", count(warns)));
215 }
216 s
217}
218
219fn pattern_summary(
222 at: usize,
223 pat_fit: usize,
224 sfx_fit: usize,
225 clipped: usize,
226 warns: usize,
227) -> String {
228 if pat_fit == 0 {
229 return if sfx_fit > 0 {
230 format!("pasted +{sfx_fit}sfx, no pattern")
231 } else {
232 "no pattern in clipboard".to_string()
233 };
234 }
235 if clipped == 0 && warns == 0 {
236 return format!("pasted pat {at} +{sfx_fit}sfx");
237 }
238 let mut s = format!("pasted pat {at}");
239 if clipped > 0 {
240 s.push_str(&format!(" {}cut", count(clipped)));
241 }
242 if warns > 0 {
243 s.push_str(&format!(" {}warn", count(warns)));
244 }
245 s
246}
247
248pub(crate) fn tagged<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
250 let open = format!("[{tag}]");
251 let close = format!("[/{tag}]");
252 let start = text.find(&open)? + open.len();
253 let end = text[start..].find(&close)? + start;
254 Some(&text[start..end])
255}
256
257const VERSION: u32 = 1;
259
260const APP: &str = "pixel8";
263
264#[derive(Serialize, Deserialize)]
267struct Blob<T> {
268 app: String,
269 version: u32,
270 #[serde(flatten)]
271 payload: T,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum ClipboardPayload {
279 Sprite {
281 w: u8,
282 h: u8,
283 #[serde(with = "crate::wire::hex_string")]
284 pixels: Vec<u8>,
285 #[serde(with = "crate::wire::hex_string")]
286 flags: Vec<u8>,
287 },
288 Sfx { slot: u8, sfx: crate::assets::Sfx },
290 Pattern {
292 pattern: MusicPattern,
293 sfx: Vec<(u8, crate::assets::Sfx)>,
294 },
295 Map {
297 w: u8,
298 h: u8,
299 #[serde(with = "crate::wire::hex_string")]
300 tiles: Vec<u8>,
301 },
302}
303
304pub fn encode(payload: &ClipboardPayload) -> String {
306 let blob = Blob {
307 app: APP.to_string(),
308 version: VERSION,
309 payload,
310 };
311 serde_json::to_string(&blob).expect("clipboard payload serializes")
314}
315
316pub fn parse(text: &str) -> Result<Pasted> {
320 let text = text.trim();
321 if text.starts_with('{') {
322 return Ok(decode_native(text)?.into_pasted());
323 }
324 crate::pico8::parse_clipboard(text)
325}
326
327fn decode_native(json: &str) -> Result<ClipboardPayload> {
330 let blob: Blob<ClipboardPayload> =
331 serde_json::from_str(json).context("malformed clipboard payload")?;
332 if blob.app != APP {
333 anyhow::bail!("not a Pixel8 clipboard blob");
334 }
335 if blob.version != VERSION {
336 anyhow::bail!("unsupported clipboard version {}", blob.version);
337 }
338 let payload = blob.payload;
339 match &payload {
340 ClipboardPayload::Sprite { w, h, pixels, .. } => {
341 let (w, h) = (*w as usize, *h as usize);
342 if w == 0 || h == 0 || w > SHEET_W || h > SHEET_H || pixels.len() != w * h {
343 anyhow::bail!("clipboard sprite dimensions inconsistent with pixel data");
344 }
345 }
346 ClipboardPayload::Map { w, h, tiles } => {
347 let (w, h) = (*w as usize, *h as usize);
348 if w == 0 || h == 0 || w > MAP_W || h > MAP_H || tiles.len() != w * h {
349 anyhow::bail!("clipboard map dimensions inconsistent with tile data");
350 }
351 }
352 _ => {}
353 }
354 Ok(payload)
355}
356
357impl ClipboardPayload {
358 fn into_pasted(self) -> Pasted {
360 match self {
361 ClipboardPayload::Sprite {
362 w,
363 h,
364 pixels,
365 flags,
366 } => Pasted::Sprites {
367 rect: PixelRect {
368 w: w as usize,
369 h: h as usize,
370 pixels,
371 },
372 flags: Some(flags),
373 },
374 ClipboardPayload::Sfx { slot, sfx } => Pasted::Sfx(SfxClip {
375 records: vec![Slotted {
376 src: slot,
377 value: sfx,
378 }],
379 patterns: Vec::new(),
380 }),
381 ClipboardPayload::Pattern { pattern, sfx } => Pasted::Sfx(SfxClip {
382 records: sfx
383 .into_iter()
384 .map(|(src, value)| Slotted { src, value })
385 .collect(),
386 patterns: vec![pattern],
387 }),
388 ClipboardPayload::Map { w, h, tiles } => Pasted::Map {
389 w: w as usize,
390 h: h as usize,
391 tiles,
392 },
393 }
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::assets::{
401 Assets, CustomWave, MusicPattern, Note, Sfx, SpriteSheet, NOTE_CUSTOM_FLAG, SFX_COUNT,
402 };
403
404 #[test]
405 fn native_sprite_round_trips_with_flags() {
406 let payload = ClipboardPayload::Sprite {
407 w: 8,
408 h: 8,
409 pixels: (0..64).map(|i| (i % 16) as u8).collect(),
410 flags: vec![0b1100_0011],
411 };
412 let Pasted::Sprites { rect, flags } = parse(&encode(&payload)).unwrap() else {
413 panic!("not sprites")
414 };
415 assert_eq!((rect.w, rect.h), (8, 8));
416 assert_eq!(rect.pixels[1], 1);
417 assert_eq!(flags, Some(vec![0b1100_0011]));
418 }
419
420 #[test]
421 fn native_sfx_round_trips_custom_wave() {
422 let sfx = Sfx {
423 custom_wave: Some(CustomWave {
424 samples: [7; 32],
425 bass: true,
426 }),
427 ..Sfx::default()
428 };
429 let payload = ClipboardPayload::Sfx {
430 slot: 3,
431 sfx: sfx.clone(),
432 };
433 let Pasted::Sfx(clip) = parse(&encode(&payload)).unwrap() else {
434 panic!("not sfx")
435 };
436 assert_eq!(clip.records.len(), 1);
437 assert_eq!(clip.records[0].src, 3);
438 assert_eq!(clip.records[0].value, sfx); assert!(clip.patterns.is_empty());
440 }
441
442 #[test]
443 fn native_pattern_round_trips_with_referenced_sfx() {
444 let pattern = MusicPattern {
445 channels: [Some(8), Some(9), None, None],
446 loop_back: false,
447 loop_start: false,
448 stop_at_end: false,
449 };
450 let payload = ClipboardPayload::Pattern {
451 pattern,
452 sfx: vec![(8, Sfx::default()), (9, Sfx::default())],
453 };
454 let Pasted::Sfx(clip) = parse(&encode(&payload)).unwrap() else {
455 panic!("not sfx")
456 };
457 assert_eq!(clip.records.len(), 2);
458 assert_eq!(clip.patterns, vec![pattern]);
459 }
460
461 #[test]
462 fn native_map_round_trips() {
463 let payload = ClipboardPayload::Map {
464 w: 3,
465 h: 2,
466 tiles: vec![1, 2, 3, 4, 5, 6],
467 };
468 let Pasted::Map { w, h, tiles } = parse(&encode(&payload)).unwrap() else {
469 panic!("not map")
470 };
471 assert_eq!((w, h), (3, 2));
472 assert_eq!(tiles, vec![1, 2, 3, 4, 5, 6]);
473 }
474
475 #[test]
476 fn native_blob_is_bare_json_marked_with_app() {
477 let blob = encode(&ClipboardPayload::Map {
478 w: 3,
479 h: 2,
480 tiles: vec![1, 2, 3, 4, 5, 6],
481 });
482 assert!(blob.starts_with(r#"{"app":"pixel8""#), "got {blob}");
483 assert!(!blob.contains("[pixel8]"), "the wrapper tag should be gone");
484 let Pasted::Map { w, h, tiles } = parse(&blob).unwrap() else {
485 panic!("not map")
486 };
487 assert_eq!((w, h, tiles), (3, 2, vec![1, 2, 3, 4, 5, 6]));
488 }
489
490 #[test]
491 fn native_map_encodes_with_kind_as_key() {
492 let blob = encode(&ClipboardPayload::Map {
493 w: 3,
494 h: 2,
495 tiles: vec![1, 2, 3, 4, 5, 6],
496 });
497 assert_eq!(
498 blob,
499 r#"{"app":"pixel8","version":1,"map":{"w":3,"h":2,"tiles":"010203040506"}}"#
500 );
501 }
502
503 #[test]
504 fn native_decode_rejects_bad_blobs_without_panicking() {
505 assert!(parse("{ not json }").is_err()); assert!(parse("{}").is_err()); assert!(parse(r#"{"hello":"world"}"#).is_err()); assert!(parse(r#"{"app":"pixel8","version":9,"map":{"w":1,"h":1,"tiles":"00"}}"#).is_err());
510 }
511
512 #[test]
513 fn native_decode_rejects_short_sprite_pixels_without_panicking() {
514 let blob = encode(&ClipboardPayload::Sprite {
516 w: 8,
517 h: 8,
518 pixels: vec![],
519 flags: vec![],
520 });
521 assert!(parse(&blob).is_err());
522 }
523
524 #[test]
525 fn native_decode_rejects_short_map_tiles_without_panicking() {
526 let blob = encode(&ClipboardPayload::Map {
528 w: 4,
529 h: 4,
530 tiles: vec![],
531 });
532 assert!(parse(&blob).is_err());
533 }
534
535 #[test]
536 fn native_decode_rejects_wrong_version() {
537 let text = r#"{"app":"pixel8","version":2,"map":{"w":1,"h":1,"tiles":"00"}}"#;
538 assert!(parse(text).is_err());
539 }
540
541 #[test]
542 fn parse_still_accepts_pico8_gfx() {
543 let Pasted::Sprites { rect, flags } = parse("[gfx]0202abcd[/gfx]").unwrap() else {
544 panic!("not sprites")
545 };
546 assert_eq!((rect.w, rect.h), (2, 2));
547 assert_eq!(flags, None); }
549
550 fn sfx_with_first_note(pitch: u8, vol: u8) -> Sfx {
551 let mut s = Sfx::default();
552 s.notes[0].pitch = pitch;
553 s.notes[0].volume = vol;
554 s
555 }
556
557 #[test]
558 fn paste_sprites_blits_and_clips() {
559 let mut sheet = SpriteSheet::default();
560 let rect = PixelRect {
561 w: 2,
562 h: 2,
563 pixels: vec![1, 2, 3, 4],
564 };
565 let r = paste_sprites(&mut sheet, &rect, 0, 0, None);
566 assert_eq!(sheet.get(0, 0), 1);
567 assert_eq!(sheet.get(1, 1), 4);
568 assert_eq!(r.clipped, 0);
569 let r2 = paste_sprites(&mut sheet, &rect, 127, 127, None);
571 assert_eq!(sheet.get(127, 127), 1);
572 assert_eq!(r2.clipped, 3);
573 }
574
575 #[test]
576 fn paste_sfx_overwrites_from_selection_only() {
577 let mut sfx = vec![Sfx::default(); SFX_COUNT];
578 sfx[0] = sfx_with_first_note(40, 5); let records = vec![Slotted {
580 src: 8,
581 value: sfx_with_first_note(12, 3),
582 }];
583 let r = paste_sfx(&mut sfx, &records, 3);
584 assert_eq!(sfx[3].notes[0].pitch, 12);
585 assert_eq!(sfx[0].notes[0].volume, 5); assert!(r.summary.contains("SFX 3"));
587 }
588
589 #[test]
590 fn paste_sfx_remaps_custom_instrument_refs() {
591 let mut inst_note = Sfx::default();
593 inst_note.notes[1] = Note {
594 pitch: 20,
595 wave: NOTE_CUSTOM_FLAG | 4,
596 volume: 5,
597 effect: 0,
598 };
599 let records = vec![
600 Slotted {
601 src: 4,
602 value: sfx_with_first_note(1, 1),
603 },
604 Slotted {
605 src: 5,
606 value: inst_note,
607 },
608 ];
609 let mut sfx = vec![Sfx::default(); SFX_COUNT];
610 paste_sfx(&mut sfx, &records, 2); assert_eq!(sfx[3].notes[1].wave, NOTE_CUSTOM_FLAG | 2);
612 }
613
614 #[test]
615 fn paste_sfx_clips_at_capacity() {
616 let mut sfx = vec![Sfx::default(); SFX_COUNT];
617 let records = vec![
618 Slotted {
619 src: 0,
620 value: sfx_with_first_note(1, 1),
621 },
622 Slotted {
623 src: 1,
624 value: sfx_with_first_note(2, 1),
625 },
626 Slotted {
627 src: 2,
628 value: sfx_with_first_note(3, 1),
629 },
630 ];
631 let r = paste_sfx(&mut sfx, &records, 62); assert_eq!(sfx[62].notes[0].pitch, 1);
633 assert_eq!(sfx[63].notes[0].pitch, 2);
634 assert_eq!(r.clipped, 1);
635 assert!(r.summary.contains("cut"));
636 }
637
638 #[test]
639 fn summaries_fit_the_status_bar() {
640 use crate::{fb::WIDTH, font::text_width};
641 let budget = WIDTH - 2; assert!(text_width(&seq_summary("SFX", 10, 54, 255, 2048)) <= budget);
644 assert!(text_width(&rect_summary(128, 128, 16384, 255)) <= budget);
645 assert!(text_width(&pattern_summary(63, 1, 64, 255, 2048)) <= budget);
646 }
647
648 #[test]
649 fn sprite_paste_summary_names_destination() {
650 let mut sheet = SpriteSheet::default();
651 let rect = PixelRect {
652 w: 8,
653 h: 8,
654 pixels: vec![1; 64],
655 };
656 let r = paste_sprites(&mut sheet, &rect, 8, 0, None);
658 assert_eq!(r.summary, "pasted 8x8 spr 1");
659 }
660
661 #[test]
662 fn paste_sprites_applies_flags_when_present() {
663 let mut sheet = SpriteSheet::default();
664 let rect = PixelRect {
665 w: 8,
666 h: 8,
667 pixels: vec![5; 64],
668 };
669 paste_sprites(&mut sheet, &rect, 8, 0, Some(&[0b1010_0101]));
671 assert_eq!(sheet.flags(1), 0b1010_0101);
672 sheet.set_flag(2, 0, true);
674 paste_sprites(&mut sheet, &rect, 16, 0, None);
675 assert_eq!(sheet.flags(2), 0b0000_0001);
676 }
677
678 #[test]
679 fn paste_pattern_appends_sfx_and_rewires_channels() {
680 let mut assets = Assets::default();
681 assets.sfx[10] = sfx_with_first_note(60, 5); let clip = SfxClip {
683 records: vec![
684 Slotted {
685 src: 8,
686 value: sfx_with_first_note(1, 4),
687 },
688 Slotted {
689 src: 9,
690 value: sfx_with_first_note(2, 4),
691 },
692 ],
693 patterns: vec![MusicPattern {
694 channels: [Some(8), Some(9), None, None],
695 loop_back: false,
696 loop_start: false,
697 stop_at_end: false,
698 }],
699 };
700 let r = paste_pattern(&mut assets, &clip, 5);
701 assert_eq!(assets.sfx[11].notes[0].pitch, 1);
703 assert_eq!(assets.sfx[12].notes[0].pitch, 2);
704 assert_eq!(assets.sfx[10].notes[0].pitch, 60);
705 assert_eq!(assets.music[5].channels, [Some(11), Some(12), None, None]);
707 assert!(r.warnings.is_empty());
708 }
709}