1use crate::{
25 assets::{
26 self, Assets, MapData, MusicPattern, Note, Sfx, SpriteSheet, MAP_H, MAP_W, MUSIC_COUNT,
27 NOTE_CUSTOM_FLAG, SFX_COUNT, SFX_LEN, SHEET_H, SHEET_W, SPRITES_PER_ROW, SPRITE_COUNT,
28 SPRITE_SIZE,
29 },
30 project::Project,
31};
32use anyhow::{anyhow, bail, Result};
33use std::{collections::HashMap, path::Path};
34
35mod clipboard;
36pub use clipboard::parse_clipboard;
37
38const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
39
40const PICO8_PNG_W: usize = 160;
42const PICO8_PNG_H: usize = 205;
43
44const ROM_LEN: usize = 0x4300;
46const PICO8_MAP_ROWS: usize = 32;
50const SHARED_BASE: usize = 0x1000;
53const SFX_MEM_LEN: usize = 68;
55
56pub const IMPORT_TEMPLATE: &str = r#"#![no_std]
59//! Imported from a PICO-8 cartridge.
60//!
61//! The graphics, map, sound and music came across intact — open the
62//! sprite/map/sfx/music editors to see them. Only the assets were imported;
63//! write your game logic in Rust here.
64use pixel8::*;
65
66#[derive(Default)]
67struct Cart;
68
69impl Game for Cart {
70 fn update(&mut self, _ctx: &mut Context) {}
71
72 fn draw(&self, gfx: &mut Graphics) {
73 gfx.clear(Color::BLACK);
74 gfx.print("Imported from PICO-8", 12, 54, Color::WHITE);
75 gfx.print("Write your game here", 16, 64, Color::LIGHT_GREY);
76 }
77}
78
79pixel8::game!(Cart);
80"#;
81
82#[derive(Debug, Clone, Default)]
85pub struct Selection {
86 pub sprites: Vec<u8>,
87 pub sfx: Vec<u8>,
88 pub music: Vec<u8>,
89}
90
91impl Selection {
92 pub fn parse(sprites: Option<&str>, sfx: Option<&str>, music: Option<&str>) -> Result<Self> {
97 if sprites.is_none() && sfx.is_none() && music.is_none() {
98 bail!("select at least one of sprites, SFX or music to import");
99 }
100 Ok(Self {
101 sprites: sprites
102 .map(|s| parse_index_ranges(s, SPRITE_COUNT))
103 .transpose()?
104 .unwrap_or_default(),
105 sfx: sfx
106 .map(|s| parse_index_ranges(s, SFX_COUNT))
107 .transpose()?
108 .unwrap_or_default(),
109 music: music
110 .map(|s| parse_index_ranges(s, MUSIC_COUNT))
111 .transpose()?
112 .unwrap_or_default(),
113 })
114 }
115}
116
117#[derive(Debug, Clone, Copy, Default)]
119pub struct Placement {
120 pub start: usize,
122 pub count: usize,
124}
125
126impl Placement {
127 fn describe(&self, kind: &str) -> Option<String> {
130 (self.count > 0).then(|| {
131 format!(
132 "{} {kind} at slots {} to {}",
133 self.count,
134 self.start,
135 self.start + self.count - 1
136 )
137 })
138 }
139}
140
141#[derive(Debug, Clone, Default)]
144pub struct Report {
145 pub sprites: Placement,
146 pub sfx: Placement,
147 pub music: Placement,
148 pub warnings: Vec<String>,
149}
150
151impl Report {
152 pub fn summary_lines(&self) -> Vec<String> {
154 [
155 self.sprites.describe("sprites"),
156 self.sfx.describe("SFX"),
157 self.music.describe("music"),
158 ]
159 .into_iter()
160 .flatten()
161 .collect()
162 }
163}
164
165pub fn append_pico8_assets(dest: &mut Assets, src: &Assets, sel: &Selection) -> Result<Report> {
172 if let Some(&s) = sel.sfx.iter().find(|&&s| s as usize >= SFX_COUNT) {
175 bail!("SFX index {s} is out of range (below {SFX_COUNT})");
176 }
177 if let Some(&m) = sel.music.iter().find(|&&m| m as usize >= MUSIC_COUNT) {
178 bail!("music index {m} is out of range (below {MUSIC_COUNT})");
179 }
180
181 let mut out = dest.clone();
183 let mut warnings = Vec::new();
184
185 let spr_start = next_free_sprite(&out.sprites);
187 if spr_start + sel.sprites.len() > SPRITE_COUNT {
188 bail!(
189 "not enough room for {} sprites: {} of {} slots free",
190 sel.sprites.len(),
191 SPRITE_COUNT - spr_start,
192 SPRITE_COUNT
193 );
194 }
195 for (i, &s) in sel.sprites.iter().enumerate() {
196 copy_sprite(&mut out.sprites, spr_start + i, &src.sprites, s as usize);
197 }
198 let sprites = Placement {
199 start: spr_start,
200 count: sel.sprites.len(),
201 };
202
203 let sfx_start = next_free_sfx(&out.sfx);
205 if sfx_start + sel.sfx.len() > SFX_COUNT {
206 bail!(
207 "not enough room for {} SFX: {} of {} slots free",
208 sel.sfx.len(),
209 SFX_COUNT - sfx_start,
210 SFX_COUNT
211 );
212 }
213 let mut sfx_map: HashMap<u8, usize> = HashMap::new();
215 for (i, &s) in sel.sfx.iter().enumerate() {
216 out.sfx[sfx_start + i] = src.sfx[s as usize].clone();
217 sfx_map.insert(s, sfx_start + i);
218 }
219 for (i, &s) in sel.sfx.iter().enumerate() {
220 remap_custom_instruments(
221 &mut out.sfx[sfx_start + i].notes,
222 &sfx_map,
223 &format!("SFX {s}"),
224 &mut warnings,
225 );
226 }
227 let sfx = Placement {
228 start: sfx_start,
229 count: sel.sfx.len(),
230 };
231
232 let mus_start = next_free_music(&out.music);
234 if mus_start + sel.music.len() > MUSIC_COUNT {
235 bail!(
236 "not enough room for {} music patterns: {} of {} slots free",
237 sel.music.len(),
238 MUSIC_COUNT - mus_start,
239 MUSIC_COUNT
240 );
241 }
242 for (i, &m) in sel.music.iter().enumerate() {
243 let mut pat = src.music[m as usize];
244 remap_music_channels(&mut pat, &sfx_map, &format!("music {m}"), &mut warnings);
245 out.music[mus_start + i] = pat;
246 }
247 let music = Placement {
248 start: mus_start,
249 count: sel.music.len(),
250 };
251
252 assets::validate(&out)?;
253 *dest = out;
254 Ok(Report {
255 sprites,
256 sfx,
257 music,
258 warnings,
259 })
260}
261
262fn next_free_sprite(sheet: &SpriteSheet) -> usize {
266 (0..SPRITE_COUNT)
267 .rev()
268 .find(|&n| sprite_used(sheet, n))
269 .map(|n| n + 1)
270 .unwrap_or(0)
271}
272
273fn sprite_used(sheet: &SpriteSheet, n: usize) -> bool {
275 if sheet.flags[n] != 0 {
276 return true;
277 }
278 let sx = (n % SPRITES_PER_ROW) * SPRITE_SIZE;
279 let sy = (n / SPRITES_PER_ROW) * SPRITE_SIZE;
280 (0..SPRITE_SIZE)
281 .any(|dy| (0..SPRITE_SIZE).any(|dx| sheet.pixels[(sy + dy) * SHEET_W + (sx + dx)] != 0))
282}
283
284pub(crate) fn next_free_sfx(sfx: &[Sfx]) -> usize {
286 (0..SFX_COUNT)
287 .rev()
288 .find(|&i| !sfx[i].is_empty() || sfx[i].custom_wave.is_some())
289 .map(|i| i + 1)
290 .unwrap_or(0)
291}
292
293fn next_free_music(music: &[MusicPattern]) -> usize {
295 (0..MUSIC_COUNT)
296 .rev()
297 .find(|&i| {
298 let p = &music[i];
299 !p.is_empty() || p.loop_back || p.loop_start || p.stop_at_end
300 })
301 .map(|i| i + 1)
302 .unwrap_or(0)
303}
304
305fn copy_sprite(dst: &mut SpriteSheet, dst_n: usize, src: &SpriteSheet, src_n: usize) {
307 let dx0 = (dst_n % SPRITES_PER_ROW) * SPRITE_SIZE;
308 let dy0 = (dst_n / SPRITES_PER_ROW) * SPRITE_SIZE;
309 let sx0 = (src_n % SPRITES_PER_ROW) * SPRITE_SIZE;
310 let sy0 = (src_n / SPRITES_PER_ROW) * SPRITE_SIZE;
311 for dy in 0..SPRITE_SIZE {
312 for dx in 0..SPRITE_SIZE {
313 dst.pixels[(dy0 + dy) * SHEET_W + (dx0 + dx)] =
314 src.pixels[(sy0 + dy) * SHEET_W + (sx0 + dx)];
315 }
316 }
317 dst.flags[dst_n] = src.flags[src_n];
318}
319
320pub(crate) fn remap_custom_instruments(
325 notes: &mut [Note],
326 sfx_map: &HashMap<u8, usize>,
327 label: &str,
328 warnings: &mut Vec<String>,
329) {
330 for note in notes.iter_mut() {
331 let Some(inst) = note.instrument() else {
332 continue;
333 };
334 match sfx_map.get(&inst) {
335 Some(&dst) if dst <= 7 => note.wave = NOTE_CUSTOM_FLAG | dst as u8,
337 Some(&dst) => warnings.push(format!(
338 "{label}: custom instrument landed in slot {dst}, which a note \
339 can't reference (only slots 0-7); left pointing at slot {inst}"
340 )),
341 None => warnings.push(format!(
342 "{label}: custom instrument {inst} was not imported; left \
343 pointing at slot {inst}"
344 )),
345 }
346 }
347}
348
349pub(crate) fn remap_music_channels(
352 pat: &mut MusicPattern,
353 sfx_map: &HashMap<u8, usize>,
354 label: &str,
355 warnings: &mut Vec<String>,
356) {
357 for ch in pat.channels.iter_mut() {
358 let Some(old) = *ch else { continue };
359 match sfx_map.get(&old) {
360 Some(&dst) => *ch = Some(dst as u8),
361 None => warnings.push(format!(
362 "{label}: channel SFX {old} was not imported; left pointing \
363 at slot {old}"
364 )),
365 }
366 }
367}
368
369fn parse_index_ranges(s: &str, max: usize) -> Result<Vec<u8>> {
373 let mut out = Vec::new();
374 for tok in s.split(',') {
375 let tok = tok.trim();
376 if tok.is_empty() {
377 bail!("empty index in selection \"{s}\"");
378 }
379 let (lo, hi) = match tok.split_once('-') {
380 Some((a, b)) => (parse_one(a, max)?, parse_one(b, max)?),
381 None => {
382 let v = parse_one(tok, max)?;
383 (v, v)
384 }
385 };
386 if hi < lo {
387 bail!("reversed range \"{tok}\": start {lo} is past end {hi}");
388 }
389 out.extend(lo..=hi);
390 }
391 out.sort_unstable();
392 out.dedup();
393 Ok(out)
394}
395
396fn parse_one(s: &str, max: usize) -> Result<u8> {
398 let v: usize = s
399 .trim()
400 .parse()
401 .map_err(|_| anyhow!("\"{s}\" is not a number"))?;
402 if v >= max {
403 bail!("index {v} is out of range (must be below {max})");
404 }
405 Ok(v as u8)
406}
407
408pub fn parse_file(path: &Path) -> Result<Assets> {
411 let bytes = std::fs::read(path)?;
412 parse_bytes(&bytes)
413}
414
415pub fn parse_bytes(bytes: &[u8]) -> Result<Assets> {
417 if bytes.starts_with(&PNG_SIG) {
418 parse_png(bytes)
419 } else {
420 let text = std::str::from_utf8(bytes)
421 .map_err(|_| anyhow!("Not a PICO-8 cartridge (neither a PNG nor UTF-8 .p8 text)"))?;
422 if !(text.contains("__gfx__") || text.contains("__lua__") || text.starts_with("pico-8")) {
423 bail!("Not a PICO-8 cartridge (missing the PICO-8 header and sections)");
424 }
425 parse_text(text)
426 }
427}
428
429pub fn import_project(src: &Path, dir: &Path) -> Result<Project> {
435 let assets = parse_file(src)?;
436
437 let crate_name = dir
438 .file_name()
439 .map(|n| n.to_string_lossy().into_owned())
440 .filter(|n| !n.is_empty())
441 .unwrap_or_else(|| "imported".into());
442 let title = cart_title(src).unwrap_or_else(|| crate_name.clone());
443
444 let mut project = Project::create(dir, &crate_name)?;
445 project.assets = assets;
446 project.assets.meta.name = title;
447 project.code = IMPORT_TEMPLATE.to_string();
448 project.save()?;
449 Ok(project)
450}
451
452fn cart_title(src: &Path) -> Option<String> {
455 let name = src.file_name()?.to_string_lossy();
456 let name = name.strip_suffix(".png").unwrap_or(&name);
457 let name = name.strip_suffix(".p8").unwrap_or(name);
458 (!name.is_empty()).then(|| name.to_string())
459}
460
461pub fn default_dir_name(src: &Path) -> String {
465 cart_title(src).unwrap_or_else(|| "imported".into())
466}
467
468fn parse_text(text: &str) -> Result<Assets> {
473 let mut assets = Assets::default();
474 let mut section = "";
475 let (mut gfx, mut gff, mut label, mut map, mut sfx, mut music) = (
476 Vec::new(),
477 Vec::new(),
478 Vec::new(),
479 Vec::new(),
480 Vec::new(),
481 Vec::new(),
482 );
483
484 for line in text.lines() {
485 if let Some(name) = section_header(line) {
486 section = name;
487 continue;
488 }
489 match section {
490 "gfx" => gfx.push(line),
491 "gff" => gff.push(line),
492 "label" => label.push(line),
493 "map" => map.push(line),
494 "sfx" => sfx.push(line),
495 "music" => music.push(line),
496 _ => {}
498 }
499 }
500
501 for (y, row) in gfx.iter().take(SHEET_H).enumerate() {
503 for (x, c) in row.trim().chars().take(SHEET_W).enumerate() {
504 if let Some(v) = hex(c) {
505 assets.sprites.pixels[y * SHEET_W + x] = v;
506 }
507 }
508 }
509
510 for (i, b) in hex_bytes(&gff.concat())
512 .into_iter()
513 .take(SPRITE_COUNT)
514 .enumerate()
515 {
516 assets.sprites.flags[i] = b;
517 }
518
519 for (y, row) in map.iter().take(PICO8_MAP_ROWS).enumerate() {
521 for (x, b) in hex_bytes(row.trim()).into_iter().take(MAP_W).enumerate() {
522 assets.map.tiles[y * MAP_W + x] = b;
523 }
524 }
525
526 let pixels = assets.sprites.pixels.clone();
530 fill_shared_map(&mut assets.map, |off| {
531 let byte = SHARED_BASE + off;
532 let (row, col) = (byte / 64, (byte % 64) * 2);
533 pixels[row * SHEET_W + col] | (pixels[row * SHEET_W + col + 1] << 4)
534 });
535
536 if !label.is_empty() {
538 let mut px = vec![0u8; SHEET_W * SHEET_H];
539 for (y, row) in label.iter().take(SHEET_H).enumerate() {
540 for (x, c) in row.trim().chars().take(SHEET_W).enumerate() {
541 if let Some(v) = hex(c) {
542 px[y * SHEET_W + x] = v;
543 }
544 }
545 }
546 assets.label = Some(px);
547 }
548
549 for (s, row) in sfx.iter().take(SFX_COUNT).enumerate() {
551 let h = hex_digits(row.trim());
552 if h.len() < 8 {
553 continue;
554 }
555 let filters = h[0] << 4 | h[1];
557 let speed = (h[2] << 4 | h[3]).max(1);
558 let loop_start = h[4] << 4 | h[5];
559 let loop_end = h[6] << 4 | h[7];
560 let mut notes = [Note::default(); SFX_LEN];
561 for (i, note) in notes.iter_mut().enumerate() {
562 let base = 8 + i * 5;
563 if base + 5 > h.len() {
564 break;
565 }
566 *note = Note {
567 pitch: (h[base] << 4 | h[base + 1]) & 0x3f,
568 wave: h[base + 2] & 0x0f,
571 volume: h[base + 3] & 7,
572 effect: h[base + 4] & 7,
573 };
574 }
575 let mut out = Sfx {
576 notes,
577 speed,
578 loop_start,
579 loop_end,
580 ..Default::default()
581 };
582 out.set_filters(filters);
583 assets.sfx[s] = out;
584 }
585
586 for (p, row) in music.iter().take(MUSIC_COUNT).enumerate() {
588 let mut toks = row.split_whitespace();
589 let (Some(flag_tok), Some(chan_tok)) = (toks.next(), toks.next()) else {
590 continue;
591 };
592 let flags = u8::from_str_radix(flag_tok, 16).unwrap_or(0);
593 let ch = hex_bytes(chan_tok);
594 if ch.len() < 4 {
595 continue;
596 }
597 assets.music[p] = MusicPattern {
598 channels: [
599 channel(ch[0]),
600 channel(ch[1]),
601 channel(ch[2]),
602 channel(ch[3]),
603 ],
604 loop_start: flags & 1 != 0,
605 loop_back: flags & 2 != 0,
606 stop_at_end: flags & 4 != 0,
607 };
608 }
609
610 assets::validate(&assets)?;
611 Ok(assets)
612}
613
614fn section_header(line: &str) -> Option<&str> {
616 let name = line.trim().strip_prefix("__")?.strip_suffix("__")?;
617 (!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric())).then_some(name)
618}
619
620fn parse_png(bytes: &[u8]) -> Result<Assets> {
625 let rom = rom_from_png(bytes)?;
626 let mut assets = Assets::default();
627
628 for y in 0..SHEET_H {
630 for x in 0..SHEET_W {
631 let byte = rom[y * 64 + x / 2];
632 assets.sprites.pixels[y * SHEET_W + x] =
633 if x & 1 == 0 { byte & 0x0f } else { byte >> 4 };
634 }
635 }
636 for y in 0..PICO8_MAP_ROWS {
638 for x in 0..MAP_W {
639 assets.map.tiles[y * MAP_W + x] = rom[0x2000 + y * MAP_W + x];
640 }
641 }
642 fill_shared_map(&mut assets.map, |off| rom[SHARED_BASE + off]);
644 assets
646 .sprites
647 .flags
648 .copy_from_slice(&rom[0x3000..0x3000 + SPRITE_COUNT]);
649 for p in 0..MUSIC_COUNT {
651 let base = 0x3100 + p * 4;
652 assets.music[p] = music_from_mem([rom[base], rom[base + 1], rom[base + 2], rom[base + 3]]);
653 }
654 for s in 0..SFX_COUNT {
656 let base = 0x3200 + s * SFX_MEM_LEN;
657 assets.sfx[s] = sfx_from_mem(&rom[base..base + SFX_MEM_LEN]);
658 }
659
660 assets::validate(&assets)?;
661 Ok(assets)
662}
663
664fn rom_from_png(bytes: &[u8]) -> Result<Vec<u8>> {
666 let (w, h, rgba) = decode_png_rgba(bytes)?;
667 if w != PICO8_PNG_W || h != PICO8_PNG_H {
668 bail!("Not a PICO-8 cart PNG (expected {PICO8_PNG_W}x{PICO8_PNG_H}, got {w}x{h})");
669 }
670 let mut rom: Vec<u8> = rgba
671 .as_chunks::<4>()
672 .0
673 .iter()
674 .map(|p| ((p[3] & 3) << 6) | ((p[0] & 3) << 4) | ((p[1] & 3) << 2) | (p[2] & 3))
675 .collect();
676 if rom.len() < ROM_LEN {
677 bail!("PICO-8 cart PNG is too small to hold a ROM");
678 }
679 rom.truncate(ROM_LEN);
680 Ok(rom)
681}
682
683fn fill_shared_map(map: &mut MapData, byte_at: impl Fn(usize) -> u8) {
692 for r in 0..(MAP_H - PICO8_MAP_ROWS) {
693 for x in 0..MAP_W {
694 map.tiles[(PICO8_MAP_ROWS + r) * MAP_W + x] = byte_at(r * MAP_W + x);
695 }
696 }
697}
698
699fn music_from_mem(ch: [u8; 4]) -> MusicPattern {
702 MusicPattern {
703 channels: [
704 channel(ch[0]),
705 channel(ch[1]),
706 channel(ch[2]),
707 channel(ch[3]),
708 ],
709 loop_start: ch[0] & 0x80 != 0,
710 loop_back: ch[1] & 0x80 != 0,
711 stop_at_end: ch[2] & 0x80 != 0,
712 }
713}
714
715fn sfx_from_mem(b: &[u8]) -> Sfx {
720 let mut notes = [Note::default(); SFX_LEN];
721 for (i, note) in notes.iter_mut().enumerate() {
722 let v = b[i * 2] as u16 | (b[i * 2 + 1] as u16) << 8;
723 let custom = (v >> 15 & 1) as u8;
726 *note = Note {
727 pitch: (v & 0x3f) as u8,
728 wave: (v >> 6 & 7) as u8 | custom << 3,
729 volume: (v >> 9 & 7) as u8,
730 effect: (v >> 12 & 7) as u8,
731 };
732 }
733 let mut sfx = Sfx {
734 notes,
735 speed: b[65].max(1),
736 loop_start: b[66],
737 loop_end: b[67],
738 ..Default::default()
739 };
740 sfx.set_filters(b[64]);
741 sfx
742}
743
744fn channel(b: u8) -> Option<u8> {
747 (b & 0x40 == 0).then_some(b & 0x3f)
748}
749
750fn decode_png_rgba(bytes: &[u8]) -> Result<(usize, usize, Vec<u8>)> {
758 if !bytes.starts_with(&PNG_SIG) {
759 bail!("not a png file");
760 }
761 let mut rest = &bytes[8..];
762 let (mut width, mut height) = (0usize, 0usize);
763 let mut idat = Vec::new();
764 let mut have_ihdr = false;
765 while rest.len() >= 12 {
766 let len = u32::from_be_bytes(rest[0..4].try_into().unwrap()) as usize;
767 let ctype = &rest[4..8];
768 if rest.len() < 12 + len {
769 bail!("truncated png chunk");
770 }
771 let data = &rest[8..8 + len];
772 match ctype {
773 b"IHDR" => {
774 if len < 13 {
775 bail!("malformed png header");
776 }
777 width = u32::from_be_bytes(data[0..4].try_into().unwrap()) as usize;
778 height = u32::from_be_bytes(data[4..8].try_into().unwrap()) as usize;
779 let (bit_depth, color_type, interlace) = (data[8], data[9], data[12]);
780 if bit_depth != 8 || color_type != 6 {
781 bail!("unsupported png: need 8-bit rgba (a pico-8 cart png is)");
782 }
783 if interlace != 0 {
784 bail!("interlaced png is not supported");
785 }
786 have_ihdr = true;
787 }
788 b"IDAT" => idat.extend_from_slice(data),
789 b"IEND" => break,
790 _ => {}
791 }
792 rest = &rest[12 + len..];
793 }
794 if !have_ihdr {
795 bail!("png has no header chunk");
796 }
797 let raw = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&idat, 64 * 1024 * 1024)
798 .map_err(|e| anyhow!("png image data is corrupted: {e:?}"))?;
799
800 const BPP: usize = 4;
801 let stride = width * BPP;
802 if raw.len() < height * (stride + 1) {
803 bail!("png image data is truncated");
804 }
805 let mut out = vec![0u8; height * stride];
806 for y in 0..height {
807 let filter = raw[y * (stride + 1)];
808 let line = &raw[y * (stride + 1) + 1..y * (stride + 1) + 1 + stride];
809 for i in 0..stride {
810 let a = if i >= BPP {
811 out[y * stride + i - BPP]
812 } else {
813 0
814 };
815 let b = if y > 0 { out[(y - 1) * stride + i] } else { 0 };
816 let c = if y > 0 && i >= BPP {
817 out[(y - 1) * stride + i - BPP]
818 } else {
819 0
820 };
821 out[y * stride + i] = match filter {
822 0 => line[i],
823 1 => line[i].wrapping_add(a),
824 2 => line[i].wrapping_add(b),
825 3 => line[i].wrapping_add(((a as u16 + b as u16) / 2) as u8),
826 4 => line[i].wrapping_add(paeth(a, b, c)),
827 f => bail!("unknown png filter type {f}"),
828 };
829 }
830 }
831 Ok((width, height, out))
832}
833
834fn paeth(a: u8, b: u8, c: u8) -> u8 {
836 let p = a as i32 + b as i32 - c as i32;
837 let (pa, pb, pc) = (
838 (p - a as i32).abs(),
839 (p - b as i32).abs(),
840 (p - c as i32).abs(),
841 );
842 if pa <= pb && pa <= pc {
843 a
844 } else if pb <= pc {
845 b
846 } else {
847 c
848 }
849}
850
851fn hex(c: char) -> Option<u8> {
856 c.to_digit(16).map(|d| d as u8)
857}
858
859fn hex_digits(s: &str) -> Vec<u8> {
861 s.chars().filter_map(hex).collect()
862}
863
864pub(crate) fn hex_bytes(s: &str) -> Vec<u8> {
866 hex_digits(s)
867 .chunks(2)
868 .filter(|c| c.len() == 2)
869 .map(|c| c[0] << 4 | c[1])
870 .collect()
871}
872
873pub(crate) fn bytes_to_hex(b: &[u8]) -> String {
875 use std::fmt::Write;
876 let mut s = String::with_capacity(b.len() * 2);
877 for &byte in b {
878 let _ = write!(s, "{byte:02x}");
879 }
880 s
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 fn sample_p8() -> String {
889 let mut s = String::from("pico-8 cartridge // http://www.pico-8.com\nversion 41\n");
890 s.push_str("__lua__\n");
891 s.push_str("function _draw()\n cls(1)\nend\n");
892 s.push_str("__gfx__\n");
894 let mut row0 = vec!['0'; 128];
895 row0[2] = 'a';
896 s.push_str(&row0.iter().collect::<String>());
897 s.push('\n');
898 let mut row1 = vec!['0'; 128];
899 row1[3] = '5';
900 s.push_str(&row1.iter().collect::<String>());
901 s.push('\n');
902 s.push_str("__gff__\n");
904 let mut gff = String::from("0380");
905 gff.push_str(&"00".repeat(254));
906 s.push_str(&gff);
907 s.push('\n');
908 s.push_str("__map__\n");
910 let mut map = String::from("002a");
911 map.push_str(&"00".repeat(126));
912 s.push_str(&map);
913 s.push('\n');
914 s.push_str("__sfx__\n");
916 let mut sfx = String::from("86100204"); sfx.push_str("21361"); sfx.push_str("10a50"); sfx.push_str(&"00000".repeat(30)); s.push_str(&sfx);
922 s.push('\n');
923 s.push_str("__music__\n");
925 s.push_str("01 01404040\n");
926 s
927 }
928
929 #[test]
930 fn parses_text_sections() {
931 let a = parse_text(&sample_p8()).unwrap();
932
933 assert_eq!(a.sprites.get(2, 0), 0xa);
934 assert_eq!(a.sprites.get(3, 1), 0x5);
935 assert_eq!(a.sprites.flags(0), 0x03);
936 assert_eq!(a.sprites.flags(1), 0x80);
937 assert_eq!(a.map.get(1, 0), 0x2a);
938
939 let n = a.sfx[0].notes[0];
940 assert_eq!((n.pitch, n.wave, n.volume, n.effect), (0x21, 3, 6, 1));
941 assert_eq!(n.instrument(), None, "a plain note is not a custom instr");
942 assert_eq!(a.sfx[0].speed, 0x10);
943 assert_eq!((a.sfx[0].loop_start, a.sfx[0].loop_end), (0x02, 0x04));
944 let f = &a.sfx[0];
946 assert!(f.noiz && f.buzz);
947 assert_eq!((f.detune, f.reverb, f.dampen), (1, 2, 1));
948
949 let n1 = a.sfx[0].notes[1];
951 assert_eq!(n1.instrument(), Some(2));
952 assert_eq!(n1.wave_index(), 2);
953
954 let m = &a.music[0];
955 assert!(m.loop_start && !m.loop_back && !m.stop_at_end);
956 assert_eq!(m.channels, [Some(1), None, None, None]);
957 }
958
959 #[test]
960 fn default_dir_name_strips_suffixes() {
961 assert_eq!(default_dir_name(Path::new("airwolf.p8")), "airwolf");
962 assert_eq!(default_dir_name(Path::new("celeste.p8.png")), "celeste");
963 assert_eq!(default_dir_name(Path::new("/a/b/jelpi.p8")), "jelpi");
964 assert_eq!(default_dir_name(Path::new("noext")), "noext");
965 }
966
967 #[test]
968 fn rejects_non_pico8_bytes() {
969 assert!(parse_bytes(b"just some text").is_err());
970 assert!(parse_bytes(&[0u8, 1, 2, 3]).is_err());
971 }
972
973 #[test]
976 fn parses_png_cart() {
977 let mut rom = vec![0u8; ROM_LEN];
979 rom[0] = 0xb0; rom[0x3000] = 0x42; rom[0x2000 + 5] = 0x09; rom[0x1000 + 3] = 0x57; let v: u16 = 0x12 | (2 << 6) | (5 << 9) | (3 << 12) | (1 << 15);
985 rom[0x3200] = (v & 0xff) as u8;
986 rom[0x3201] = (v >> 8) as u8;
987 rom[0x3200 + 64] = 0x1a; rom[0x3200 + 65] = 0x18; rom[0x3100] = 0x07;
991 rom[0x3100 + 2] = 0x80;
992
993 let png = build_pico8_png(&rom);
994 let a = parse_bytes(&png).unwrap();
995
996 assert_eq!(a.sprites.get(0, 0), 0x0);
997 assert_eq!(a.sprites.get(1, 0), 0xb);
998 assert_eq!(a.sprites.flags(0), 0x42);
999 assert_eq!(a.map.get(5, 0), 0x09);
1000 assert_eq!(a.map.get(3, 32), 0x57);
1002 let n = a.sfx[0].notes[0];
1003 assert_eq!((n.pitch, n.volume, n.effect), (0x12, 5, 3));
1004 assert_eq!(n.instrument(), Some(2), "bit 15 marks a custom instrument");
1005 assert_eq!(a.sfx[0].speed, 0x18);
1006 assert!(a.sfx[0].noiz && !a.sfx[0].buzz);
1007 assert_eq!(
1008 (a.sfx[0].detune, a.sfx[0].reverb, a.sfx[0].dampen),
1009 (0, 1, 0)
1010 );
1011 assert_eq!(a.music[0].channels[0], Some(7));
1012 assert!(a.music[0].stop_at_end);
1013 }
1014
1015 fn build_pico8_png(rom: &[u8]) -> Vec<u8> {
1018 let (w, h) = (PICO8_PNG_W, PICO8_PNG_H);
1019 let mut rgba = vec![0u8; w * h * 4];
1020 for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
1021 let byte = rom.get(i).copied().unwrap_or(0);
1022 px[0] = byte >> 4 & 3; px[1] = byte >> 2 & 3; px[2] = byte & 3; px[3] = byte >> 6 & 3; }
1027 let mut raw = Vec::with_capacity(h * (1 + w * 4));
1028 for y in 0..h {
1029 raw.push(0);
1030 raw.extend_from_slice(&rgba[y * w * 4..(y + 1) * w * 4]);
1031 }
1032
1033 let mut png = PNG_SIG.to_vec();
1034 let mut ihdr = Vec::new();
1035 ihdr.extend((w as u32).to_be_bytes());
1036 ihdr.extend((h as u32).to_be_bytes());
1037 ihdr.extend([8, 6, 0, 0, 0]);
1038 write_chunk(&mut png, *b"IHDR", &ihdr);
1039 let idat = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
1040 write_chunk(&mut png, *b"IDAT", &idat);
1041 write_chunk(&mut png, *b"IEND", &[]);
1042 png
1043 }
1044
1045 fn write_chunk(out: &mut Vec<u8>, ctype: [u8; 4], data: &[u8]) {
1046 out.extend((data.len() as u32).to_be_bytes());
1047 out.extend(ctype);
1048 out.extend_from_slice(data);
1049 let mut h = crc32fast::Hasher::new();
1050 h.update(&ctype);
1051 h.update(data);
1052 out.extend(h.finalize().to_be_bytes());
1053 }
1054
1055 #[test]
1056 fn import_project_writes_assets() {
1057 let base = std::env::temp_dir().join(format!("pixel8_p8_import_{}", std::process::id()));
1058 let _ = std::fs::remove_dir_all(&base);
1059 let src = base.join("celeste.p8");
1060 std::fs::create_dir_all(&base).unwrap();
1061 std::fs::write(&src, sample_p8()).unwrap();
1062
1063 let dir = base.join("ported");
1064 let project = import_project(&src, &dir).unwrap();
1065
1066 assert_eq!(project.name, "ported");
1067 assert_eq!(project.assets.meta.name, "celeste");
1068 assert_eq!(project.assets.sprites.get(2, 0), 0xa);
1069 assert!(project.code.contains("Imported from PICO-8"));
1070 assert!(!dir.join("pico8.lua").exists());
1072
1073 std::fs::remove_dir_all(&base).unwrap();
1074 }
1075
1076 #[test]
1077 fn parse_ranges_singles_and_ranges() {
1078 assert_eq!(
1079 parse_index_ranges("0-3,5,8-9", 64).unwrap(),
1080 vec![0, 1, 2, 3, 5, 8, 9]
1081 );
1082 assert_eq!(parse_index_ranges("7", 64).unwrap(), vec![7]);
1083 assert_eq!(
1085 parse_index_ranges(" 3, 1 ,1, 2 ", 64).unwrap(),
1086 vec![1, 2, 3]
1087 );
1088 assert_eq!(parse_index_ranges("255", 256).unwrap(), vec![255]);
1090 }
1091
1092 #[test]
1093 fn parse_ranges_rejects_bad_input() {
1094 assert!(parse_index_ranges("", 64).is_err(), "empty string");
1095 assert!(parse_index_ranges("1,,2", 64).is_err(), "empty token");
1096 assert!(parse_index_ranges("64", 64).is_err(), "out of range");
1097 assert!(parse_index_ranges("5-3", 64).is_err(), "reversed range");
1098 assert!(parse_index_ranges("x", 64).is_err(), "non-numeric");
1099 assert!(
1100 parse_index_ranges("0-99", 64).is_err(),
1101 "range end out of bounds"
1102 );
1103 }
1104
1105 #[test]
1106 fn selection_parse_requires_one_kind() {
1107 assert!(Selection::parse(None, None, None).is_err());
1108 let s = Selection::parse(Some("0-2"), None, Some("3")).unwrap();
1109 assert_eq!(s.sprites, vec![0, 1, 2]);
1110 assert!(s.sfx.is_empty());
1111 assert_eq!(s.music, vec![3]);
1112 }
1113
1114 #[test]
1115 fn append_sprites_into_empty_lands_at_zero() {
1116 let mut src = Assets::default();
1117 src.sprites.set(0, 0, 7); src.sprites.set(8, 0, 9); src.sprites.flags[1] = 0x05;
1120 let mut dest = Assets::default();
1121 let sel = Selection {
1122 sprites: vec![0, 1],
1123 sfx: vec![],
1124 music: vec![],
1125 };
1126
1127 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1128
1129 assert_eq!((r.sprites.start, r.sprites.count), (0, 2));
1130 assert_eq!(dest.sprites.get(0, 0), 7);
1131 assert_eq!(dest.sprites.get(8, 0), 9);
1132 assert_eq!(dest.sprites.flags(1), 0x05);
1133 assert!(r.warnings.is_empty());
1134 }
1135
1136 #[test]
1137 fn append_sprites_after_last_used_slot() {
1138 let mut dest = Assets::default();
1139 dest.sprites.set(0, 0, 1); dest.sprites.flags[3] = 0x01; let mut src = Assets::default();
1142 src.sprites.set(0, 0, 0xc);
1143 let sel = Selection {
1144 sprites: vec![0],
1145 sfx: vec![],
1146 music: vec![],
1147 };
1148
1149 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1150
1151 assert_eq!(r.sprites.start, 4);
1153 assert_eq!(dest.sprites.get(32, 0), 0xc);
1155 assert_eq!(dest.sprites.get(0, 0), 1);
1157 }
1158
1159 #[test]
1160 fn append_music_remaps_imported_sfx_refs() {
1161 let mut src = Assets::default();
1162 src.sfx[5].notes[0].volume = 5;
1163 src.sfx[6].notes[0].volume = 5;
1164 src.music[0].channels = [Some(5), Some(6), None, None];
1165 let mut dest = Assets::default();
1166 let sel = Selection {
1167 sprites: vec![],
1168 sfx: vec![5, 6],
1169 music: vec![0],
1170 };
1171
1172 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1173
1174 assert_eq!((r.sfx.start, r.sfx.count), (0, 2));
1175 assert_eq!((r.music.start, r.music.count), (0, 1));
1176 assert_eq!(dest.music[0].channels, [Some(0), Some(1), None, None]);
1178 assert!(r.warnings.is_empty());
1179 }
1180
1181 #[test]
1182 fn append_music_keeps_and_warns_on_dangling_ref() {
1183 let mut src = Assets::default();
1184 src.sfx[5].notes[0].volume = 5;
1185 src.music[0].channels = [Some(5), Some(9), None, None];
1187 let mut dest = Assets::default();
1188 let sel = Selection {
1189 sprites: vec![],
1190 sfx: vec![5],
1191 music: vec![0],
1192 };
1193
1194 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1195
1196 assert_eq!(dest.music[0].channels, [Some(0), Some(9), None, None]);
1197 assert_eq!(r.warnings.len(), 1);
1198 assert!(
1199 r.warnings[0].contains('9'),
1200 "warning names the dangling slot"
1201 );
1202 }
1203
1204 #[test]
1205 fn append_custom_instrument_past_slot7_keeps_and_warns() {
1206 let mut src = Assets::default();
1207 src.sfx[2].notes[0].volume = 5; src.sfx[10].notes[0] = Note {
1209 pitch: 20,
1210 wave: NOTE_CUSTOM_FLAG | 2, volume: 5,
1212 effect: 0,
1213 };
1214 let mut dest = Assets::default();
1215 for i in 0..8 {
1217 dest.sfx[i].notes[0].volume = 1;
1218 }
1219 let sel = Selection {
1220 sprites: vec![],
1221 sfx: vec![2, 10],
1222 music: vec![],
1223 };
1224
1225 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1226
1227 assert_eq!(r.sfx.start, 8); assert_eq!(dest.sfx[9].notes[0].instrument(), Some(2));
1231 assert!(r.warnings.iter().any(|w| w.contains("custom instrument")));
1232 }
1233
1234 #[test]
1235 fn append_overflow_errors_and_leaves_dest_untouched() {
1236 let mut dest = Assets::default();
1237 dest.sfx[63].notes[0].volume = 1; let src = Assets::default();
1239 let sel = Selection {
1240 sprites: vec![],
1241 sfx: vec![0],
1242 music: vec![],
1243 };
1244
1245 let err = append_pico8_assets(&mut dest, &src, &sel).unwrap_err();
1246
1247 assert!(err.to_string().contains("room"), "got: {err}");
1248 assert_eq!(dest.sfx[63].notes[0].volume, 1);
1250 }
1251
1252 #[test]
1253 fn append_does_not_overwrite_custom_wave_instrument() {
1254 let mut dest = Assets::default();
1255 dest.sfx[3].custom_wave = Some(assets::CustomWave {
1257 samples: [0; SFX_LEN],
1258 bass: false,
1259 });
1260 let mut src = Assets::default();
1261 src.sfx[0].notes[0].volume = 5;
1262 let sel = Selection {
1263 sprites: vec![],
1264 sfx: vec![0],
1265 music: vec![],
1266 };
1267
1268 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1269
1270 assert_eq!(r.sfx.start, 4);
1272 assert!(dest.sfx[3].custom_wave.is_some(), "instrument preserved");
1273 }
1274
1275 #[test]
1276 fn append_does_not_overwrite_flow_only_music_pattern() {
1277 let mut dest = Assets::default();
1278 dest.music[2].stop_at_end = true;
1280 let mut src = Assets::default();
1281 src.music[0].channels[0] = Some(0);
1282 let sel = Selection {
1283 sprites: vec![],
1284 sfx: vec![],
1285 music: vec![0],
1286 };
1287
1288 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1289
1290 assert_eq!(r.music.start, 3, "import lands after the flow-only pattern");
1291 assert!(dest.music[2].stop_at_end, "flow pattern preserved");
1292 }
1293
1294 #[test]
1295 fn append_sprite_overflow_errors_and_keeps_dest() {
1296 let mut dest = Assets::default();
1297 dest.sprites.set(120, 120, 1); let src = Assets::default();
1299 let sel = Selection {
1300 sprites: vec![0],
1301 sfx: vec![],
1302 music: vec![],
1303 };
1304
1305 let err = append_pico8_assets(&mut dest, &src, &sel).unwrap_err();
1306
1307 assert!(err.to_string().contains("room"), "got: {err}");
1308 assert_eq!(dest.sprites.get(120, 120), 1); }
1310
1311 #[test]
1312 fn append_music_overflow_errors() {
1313 let mut dest = Assets::default();
1314 dest.music[63].channels[0] = Some(0); let src = Assets::default();
1316 let sel = Selection {
1317 sprites: vec![],
1318 sfx: vec![],
1319 music: vec![0],
1320 };
1321
1322 let err = append_pico8_assets(&mut dest, &src, &sel).unwrap_err();
1323 assert!(err.to_string().contains("room"), "got: {err}");
1324 }
1325
1326 #[test]
1327 fn hex_round_trips() {
1328 let b = [0x00u8, 0x08, 0xff, 0x10, 0xab];
1329 assert_eq!(hex_bytes(&bytes_to_hex(&b)), b);
1330 }
1331
1332 #[test]
1333 fn append_round_trips_through_a_project() {
1334 let base = std::env::temp_dir().join(format!("pixel8_append_{}", std::process::id()));
1335 let _ = std::fs::remove_dir_all(&base);
1336 let src = base.join("celeste.p8");
1337 std::fs::create_dir_all(&base).unwrap();
1338 std::fs::write(&src, sample_p8()).unwrap();
1339
1340 let dir = base.join("dest");
1342 let mut project = Project::create(&dir, "dest").unwrap();
1343 project.assets.sprites.set(0, 0, 4);
1344 project.save().unwrap();
1345
1346 let assets = parse_file(&src).unwrap();
1348 let sel = Selection {
1349 sprites: vec![0],
1350 sfx: vec![],
1351 music: vec![],
1352 };
1353 append_pico8_assets(&mut project.assets, &assets, &sel).unwrap();
1354 project.save().unwrap();
1355
1356 let reloaded = Project::load(&dir).unwrap();
1358 assert_eq!(reloaded.assets.sprites.get(10, 0), 0xa);
1360 assert_eq!(reloaded.assets.sprites.get(0, 0), 4); std::fs::remove_dir_all(&base).unwrap();
1363 }
1364}