use crate::{
assets::{
Assets, MusicPattern, Sfx, SpriteSheet, MAP_H, MAP_W, MUSIC_COUNT, SFX_COUNT, SHEET_H,
SHEET_W, SPRITES_PER_ROW, SPRITE_SIZE,
},
pico8::{next_free_sfx, remap_custom_instruments, remap_music_channels},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub struct PixelRect {
pub w: usize,
pub h: usize,
pub pixels: Vec<u8>,
}
pub struct Slotted<T> {
pub src: u8,
pub value: T,
}
pub struct SfxClip {
pub records: Vec<Slotted<Sfx>>,
pub patterns: Vec<MusicPattern>,
}
pub enum Pasted {
Sprites {
rect: PixelRect,
flags: Option<Vec<u8>>,
},
Sfx(SfxClip),
Map {
w: usize,
h: usize,
tiles: Vec<u8>,
},
}
pub struct PasteReport {
pub summary: String,
pub clipped: usize,
pub warnings: Vec<String>,
}
pub fn paste_sprites(
sheet: &mut SpriteSheet,
rect: &PixelRect,
x0: i32,
y0: i32,
flags: Option<&[u8]>,
) -> PasteReport {
let mut clipped = 0;
for ry in 0..rect.h {
for rx in 0..rect.w {
let (x, y) = (x0 + rx as i32, y0 + ry as i32);
if (0..SHEET_W as i32).contains(&x) && (0..SHEET_H as i32).contains(&y) {
sheet.set(x, y, rect.pixels[ry * rect.w + rx]);
} else {
clipped += 1;
}
}
}
let slot =
(y0.max(0) as usize / SPRITE_SIZE) * SPRITES_PER_ROW + (x0.max(0) as usize / SPRITE_SIZE);
if let Some(flags) = flags {
let cols = rect.w.div_ceil(SPRITE_SIZE);
let sprite_x0 = (x0.max(0) as usize) / SPRITE_SIZE;
let sprite_y0 = (y0.max(0) as usize) / SPRITE_SIZE;
for (i, &f) in flags.iter().enumerate() {
let n = (sprite_y0 + i / cols) * SPRITES_PER_ROW + (sprite_x0 + i % cols);
if n < sheet.flags.len() {
sheet.flags[n] = f;
}
}
}
PasteReport {
summary: rect_summary(rect.w, rect.h, clipped, slot),
clipped,
warnings: Vec::new(),
}
}
pub fn paste_sfx(sfx: &mut [Sfx], records: &[Slotted<Sfx>], at: usize) -> PasteReport {
let fit = records.len().min(SFX_COUNT.saturating_sub(at));
let mut sfx_map: HashMap<u8, usize> = HashMap::new();
for (i, rec) in records.iter().take(fit).enumerate() {
sfx[at + i] = rec.value.clone();
sfx_map.insert(rec.src, at + i);
}
let mut warnings = Vec::new();
for (i, rec) in records.iter().take(fit).enumerate() {
remap_custom_instruments(
&mut sfx[at + i].notes,
&sfx_map,
&format!("SFX {}", rec.src),
&mut warnings,
);
}
let clipped = records.len() - fit;
PasteReport {
summary: seq_summary("SFX", at, fit, clipped, warnings.len()),
clipped,
warnings,
}
}
pub fn paste_pattern(assets: &mut Assets, clip: &SfxClip, at_pattern: usize) -> PasteReport {
let mut warnings = Vec::new();
let sfx_start = next_free_sfx(&assets.sfx);
let sfx_fit = clip.records.len().min(SFX_COUNT.saturating_sub(sfx_start));
let mut sfx_map: HashMap<u8, usize> = HashMap::new();
for (i, rec) in clip.records.iter().take(sfx_fit).enumerate() {
assets.sfx[sfx_start + i] = rec.value.clone();
sfx_map.insert(rec.src, sfx_start + i);
}
for (i, rec) in clip.records.iter().take(sfx_fit).enumerate() {
remap_custom_instruments(
&mut assets.sfx[sfx_start + i].notes,
&sfx_map,
&format!("SFX {}", rec.src),
&mut warnings,
);
}
let pat_fit = clip
.patterns
.len()
.min(MUSIC_COUNT.saturating_sub(at_pattern));
for (i, pat) in clip.patterns.iter().take(pat_fit).enumerate() {
let mut p = *pat;
remap_music_channels(&mut p, &sfx_map, &format!("pattern {i}"), &mut warnings);
assets.music[at_pattern + i] = p;
}
let clipped = (clip.records.len() - sfx_fit) + (clip.patterns.len() - pat_fit);
let summary = pattern_summary(at_pattern, pat_fit, sfx_fit, clipped, warnings.len());
PasteReport {
summary,
clipped,
warnings,
}
}
fn count(n: usize) -> String {
if n > 99 {
"99+".to_string()
} else {
n.to_string()
}
}
fn rect_summary(w: usize, h: usize, clipped: usize, slot: usize) -> String {
let mut s = format!("pasted {w}x{h} spr {slot}");
if clipped > 0 {
s.push_str(&format!(" {}cut", count(clipped)));
}
s
}
fn seq_summary(kind: &str, at: usize, fit: usize, clipped: usize, warns: usize) -> String {
let mut s = match fit {
0 => return format!("no free {kind} slot"),
1 => format!("pasted {kind} {at}"),
n => format!("pasted {kind} {at}-{}", at + n - 1),
};
if clipped > 0 {
s.push_str(&format!(" {}cut", count(clipped)));
}
if warns > 0 {
s.push_str(&format!(" {}warn", count(warns)));
}
s
}
fn pattern_summary(
at: usize,
pat_fit: usize,
sfx_fit: usize,
clipped: usize,
warns: usize,
) -> String {
if pat_fit == 0 {
return if sfx_fit > 0 {
format!("pasted +{sfx_fit}sfx, no pattern")
} else {
"no pattern in clipboard".to_string()
};
}
if clipped == 0 && warns == 0 {
return format!("pasted pat {at} +{sfx_fit}sfx");
}
let mut s = format!("pasted pat {at}");
if clipped > 0 {
s.push_str(&format!(" {}cut", count(clipped)));
}
if warns > 0 {
s.push_str(&format!(" {}warn", count(warns)));
}
s
}
pub(crate) fn tagged<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("[{tag}]");
let close = format!("[/{tag}]");
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
Some(&text[start..end])
}
const VERSION: u32 = 1;
const APP: &str = "pixel8";
#[derive(Serialize, Deserialize)]
struct Blob<T> {
app: String,
version: u32,
#[serde(flatten)]
payload: T,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClipboardPayload {
Sprite {
w: u8,
h: u8,
#[serde(with = "crate::wire::hex_string")]
pixels: Vec<u8>,
#[serde(with = "crate::wire::hex_string")]
flags: Vec<u8>,
},
Sfx { slot: u8, sfx: crate::assets::Sfx },
Pattern {
pattern: MusicPattern,
sfx: Vec<(u8, crate::assets::Sfx)>,
},
Map {
w: u8,
h: u8,
#[serde(with = "crate::wire::hex_string")]
tiles: Vec<u8>,
},
}
pub fn encode(payload: &ClipboardPayload) -> String {
let blob = Blob {
app: APP.to_string(),
version: VERSION,
payload,
};
serde_json::to_string(&blob).expect("clipboard payload serializes")
}
pub fn parse(text: &str) -> Result<Pasted> {
let text = text.trim();
if text.starts_with('{') {
return Ok(decode_native(text)?.into_pasted());
}
crate::pico8::parse_clipboard(text)
}
fn decode_native(json: &str) -> Result<ClipboardPayload> {
let blob: Blob<ClipboardPayload> =
serde_json::from_str(json).context("malformed clipboard payload")?;
if blob.app != APP {
anyhow::bail!("not a Pixel8 clipboard blob");
}
if blob.version != VERSION {
anyhow::bail!("unsupported clipboard version {}", blob.version);
}
let payload = blob.payload;
match &payload {
ClipboardPayload::Sprite { w, h, pixels, .. } => {
let (w, h) = (*w as usize, *h as usize);
if w == 0 || h == 0 || w > SHEET_W || h > SHEET_H || pixels.len() != w * h {
anyhow::bail!("clipboard sprite dimensions inconsistent with pixel data");
}
}
ClipboardPayload::Map { w, h, tiles } => {
let (w, h) = (*w as usize, *h as usize);
if w == 0 || h == 0 || w > MAP_W || h > MAP_H || tiles.len() != w * h {
anyhow::bail!("clipboard map dimensions inconsistent with tile data");
}
}
_ => {}
}
Ok(payload)
}
impl ClipboardPayload {
fn into_pasted(self) -> Pasted {
match self {
ClipboardPayload::Sprite {
w,
h,
pixels,
flags,
} => Pasted::Sprites {
rect: PixelRect {
w: w as usize,
h: h as usize,
pixels,
},
flags: Some(flags),
},
ClipboardPayload::Sfx { slot, sfx } => Pasted::Sfx(SfxClip {
records: vec![Slotted {
src: slot,
value: sfx,
}],
patterns: Vec::new(),
}),
ClipboardPayload::Pattern { pattern, sfx } => Pasted::Sfx(SfxClip {
records: sfx
.into_iter()
.map(|(src, value)| Slotted { src, value })
.collect(),
patterns: vec![pattern],
}),
ClipboardPayload::Map { w, h, tiles } => Pasted::Map {
w: w as usize,
h: h as usize,
tiles,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::{
Assets, CustomWave, MusicPattern, Note, Sfx, SpriteSheet, NOTE_CUSTOM_FLAG, SFX_COUNT,
};
#[test]
fn native_sprite_round_trips_with_flags() {
let payload = ClipboardPayload::Sprite {
w: 8,
h: 8,
pixels: (0..64).map(|i| (i % 16) as u8).collect(),
flags: vec![0b1100_0011],
};
let Pasted::Sprites { rect, flags } = parse(&encode(&payload)).unwrap() else {
panic!("not sprites")
};
assert_eq!((rect.w, rect.h), (8, 8));
assert_eq!(rect.pixels[1], 1);
assert_eq!(flags, Some(vec![0b1100_0011]));
}
#[test]
fn native_sfx_round_trips_custom_wave() {
let sfx = Sfx {
custom_wave: Some(CustomWave {
samples: [7; 32],
bass: true,
}),
..Sfx::default()
};
let payload = ClipboardPayload::Sfx {
slot: 3,
sfx: sfx.clone(),
};
let Pasted::Sfx(clip) = parse(&encode(&payload)).unwrap() else {
panic!("not sfx")
};
assert_eq!(clip.records.len(), 1);
assert_eq!(clip.records[0].src, 3);
assert_eq!(clip.records[0].value, sfx); assert!(clip.patterns.is_empty());
}
#[test]
fn native_pattern_round_trips_with_referenced_sfx() {
let pattern = MusicPattern {
channels: [Some(8), Some(9), None, None],
loop_back: false,
loop_start: false,
stop_at_end: false,
};
let payload = ClipboardPayload::Pattern {
pattern,
sfx: vec![(8, Sfx::default()), (9, Sfx::default())],
};
let Pasted::Sfx(clip) = parse(&encode(&payload)).unwrap() else {
panic!("not sfx")
};
assert_eq!(clip.records.len(), 2);
assert_eq!(clip.patterns, vec![pattern]);
}
#[test]
fn native_map_round_trips() {
let payload = ClipboardPayload::Map {
w: 3,
h: 2,
tiles: vec![1, 2, 3, 4, 5, 6],
};
let Pasted::Map { w, h, tiles } = parse(&encode(&payload)).unwrap() else {
panic!("not map")
};
assert_eq!((w, h), (3, 2));
assert_eq!(tiles, vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn native_blob_is_bare_json_marked_with_app() {
let blob = encode(&ClipboardPayload::Map {
w: 3,
h: 2,
tiles: vec![1, 2, 3, 4, 5, 6],
});
assert!(blob.starts_with(r#"{"app":"pixel8""#), "got {blob}");
assert!(!blob.contains("[pixel8]"), "the wrapper tag should be gone");
let Pasted::Map { w, h, tiles } = parse(&blob).unwrap() else {
panic!("not map")
};
assert_eq!((w, h, tiles), (3, 2, vec![1, 2, 3, 4, 5, 6]));
}
#[test]
fn native_map_encodes_with_kind_as_key() {
let blob = encode(&ClipboardPayload::Map {
w: 3,
h: 2,
tiles: vec![1, 2, 3, 4, 5, 6],
});
assert_eq!(
blob,
r#"{"app":"pixel8","version":1,"map":{"w":3,"h":2,"tiles":"010203040506"}}"#
);
}
#[test]
fn native_decode_rejects_bad_blobs_without_panicking() {
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());
}
#[test]
fn native_decode_rejects_short_sprite_pixels_without_panicking() {
let blob = encode(&ClipboardPayload::Sprite {
w: 8,
h: 8,
pixels: vec![],
flags: vec![],
});
assert!(parse(&blob).is_err());
}
#[test]
fn native_decode_rejects_short_map_tiles_without_panicking() {
let blob = encode(&ClipboardPayload::Map {
w: 4,
h: 4,
tiles: vec![],
});
assert!(parse(&blob).is_err());
}
#[test]
fn native_decode_rejects_wrong_version() {
let text = r#"{"app":"pixel8","version":2,"map":{"w":1,"h":1,"tiles":"00"}}"#;
assert!(parse(text).is_err());
}
#[test]
fn parse_still_accepts_pico8_gfx() {
let Pasted::Sprites { rect, flags } = parse("[gfx]0202abcd[/gfx]").unwrap() else {
panic!("not sprites")
};
assert_eq!((rect.w, rect.h), (2, 2));
assert_eq!(flags, None); }
fn sfx_with_first_note(pitch: u8, vol: u8) -> Sfx {
let mut s = Sfx::default();
s.notes[0].pitch = pitch;
s.notes[0].volume = vol;
s
}
#[test]
fn paste_sprites_blits_and_clips() {
let mut sheet = SpriteSheet::default();
let rect = PixelRect {
w: 2,
h: 2,
pixels: vec![1, 2, 3, 4],
};
let r = paste_sprites(&mut sheet, &rect, 0, 0, None);
assert_eq!(sheet.get(0, 0), 1);
assert_eq!(sheet.get(1, 1), 4);
assert_eq!(r.clipped, 0);
let r2 = paste_sprites(&mut sheet, &rect, 127, 127, None);
assert_eq!(sheet.get(127, 127), 1);
assert_eq!(r2.clipped, 3);
}
#[test]
fn paste_sfx_overwrites_from_selection_only() {
let mut sfx = vec![Sfx::default(); SFX_COUNT];
sfx[0] = sfx_with_first_note(40, 5); let records = vec![Slotted {
src: 8,
value: sfx_with_first_note(12, 3),
}];
let r = paste_sfx(&mut sfx, &records, 3);
assert_eq!(sfx[3].notes[0].pitch, 12);
assert_eq!(sfx[0].notes[0].volume, 5); assert!(r.summary.contains("SFX 3"));
}
#[test]
fn paste_sfx_remaps_custom_instrument_refs() {
let mut inst_note = Sfx::default();
inst_note.notes[1] = Note {
pitch: 20,
wave: NOTE_CUSTOM_FLAG | 4,
volume: 5,
effect: 0,
};
let records = vec![
Slotted {
src: 4,
value: sfx_with_first_note(1, 1),
},
Slotted {
src: 5,
value: inst_note,
},
];
let mut sfx = vec![Sfx::default(); SFX_COUNT];
paste_sfx(&mut sfx, &records, 2); assert_eq!(sfx[3].notes[1].wave, NOTE_CUSTOM_FLAG | 2);
}
#[test]
fn paste_sfx_clips_at_capacity() {
let mut sfx = vec![Sfx::default(); SFX_COUNT];
let records = vec![
Slotted {
src: 0,
value: sfx_with_first_note(1, 1),
},
Slotted {
src: 1,
value: sfx_with_first_note(2, 1),
},
Slotted {
src: 2,
value: sfx_with_first_note(3, 1),
},
];
let r = paste_sfx(&mut sfx, &records, 62); assert_eq!(sfx[62].notes[0].pitch, 1);
assert_eq!(sfx[63].notes[0].pitch, 2);
assert_eq!(r.clipped, 1);
assert!(r.summary.contains("cut"));
}
#[test]
fn summaries_fit_the_status_bar() {
use crate::{fb::WIDTH, font::text_width};
let budget = WIDTH - 2; assert!(text_width(&seq_summary("SFX", 10, 54, 255, 2048)) <= budget);
assert!(text_width(&rect_summary(128, 128, 16384, 255)) <= budget);
assert!(text_width(&pattern_summary(63, 1, 64, 255, 2048)) <= budget);
}
#[test]
fn sprite_paste_summary_names_destination() {
let mut sheet = SpriteSheet::default();
let rect = PixelRect {
w: 8,
h: 8,
pixels: vec![1; 64],
};
let r = paste_sprites(&mut sheet, &rect, 8, 0, None);
assert_eq!(r.summary, "pasted 8x8 spr 1");
}
#[test]
fn paste_sprites_applies_flags_when_present() {
let mut sheet = SpriteSheet::default();
let rect = PixelRect {
w: 8,
h: 8,
pixels: vec![5; 64],
};
paste_sprites(&mut sheet, &rect, 8, 0, Some(&[0b1010_0101]));
assert_eq!(sheet.flags(1), 0b1010_0101);
sheet.set_flag(2, 0, true);
paste_sprites(&mut sheet, &rect, 16, 0, None);
assert_eq!(sheet.flags(2), 0b0000_0001);
}
#[test]
fn paste_pattern_appends_sfx_and_rewires_channels() {
let mut assets = Assets::default();
assets.sfx[10] = sfx_with_first_note(60, 5); let clip = SfxClip {
records: vec![
Slotted {
src: 8,
value: sfx_with_first_note(1, 4),
},
Slotted {
src: 9,
value: sfx_with_first_note(2, 4),
},
],
patterns: vec![MusicPattern {
channels: [Some(8), Some(9), None, None],
loop_back: false,
loop_start: false,
stop_at_end: false,
}],
};
let r = paste_pattern(&mut assets, &clip, 5);
assert_eq!(assets.sfx[11].notes[0].pitch, 1);
assert_eq!(assets.sfx[12].notes[0].pitch, 2);
assert_eq!(assets.sfx[10].notes[0].pitch, 60);
assert_eq!(assets.music[5].channels, [Some(11), Some(12), None, None]);
assert!(r.warnings.is_empty());
}
}