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 .chunks_exact(4)
672 .map(|p| ((p[3] & 3) << 6) | ((p[0] & 3) << 4) | ((p[1] & 3) << 2) | (p[2] & 3))
673 .collect();
674 if rom.len() < ROM_LEN {
675 bail!("PICO-8 cart PNG is too small to hold a ROM");
676 }
677 rom.truncate(ROM_LEN);
678 Ok(rom)
679}
680
681fn fill_shared_map(map: &mut MapData, byte_at: impl Fn(usize) -> u8) {
690 for r in 0..(MAP_H - PICO8_MAP_ROWS) {
691 for x in 0..MAP_W {
692 map.tiles[(PICO8_MAP_ROWS + r) * MAP_W + x] = byte_at(r * MAP_W + x);
693 }
694 }
695}
696
697fn music_from_mem(ch: [u8; 4]) -> MusicPattern {
700 MusicPattern {
701 channels: [
702 channel(ch[0]),
703 channel(ch[1]),
704 channel(ch[2]),
705 channel(ch[3]),
706 ],
707 loop_start: ch[0] & 0x80 != 0,
708 loop_back: ch[1] & 0x80 != 0,
709 stop_at_end: ch[2] & 0x80 != 0,
710 }
711}
712
713fn sfx_from_mem(b: &[u8]) -> Sfx {
718 let mut notes = [Note::default(); SFX_LEN];
719 for (i, note) in notes.iter_mut().enumerate() {
720 let v = b[i * 2] as u16 | (b[i * 2 + 1] as u16) << 8;
721 let custom = (v >> 15 & 1) as u8;
724 *note = Note {
725 pitch: (v & 0x3f) as u8,
726 wave: (v >> 6 & 7) as u8 | custom << 3,
727 volume: (v >> 9 & 7) as u8,
728 effect: (v >> 12 & 7) as u8,
729 };
730 }
731 let mut sfx = Sfx {
732 notes,
733 speed: b[65].max(1),
734 loop_start: b[66],
735 loop_end: b[67],
736 ..Default::default()
737 };
738 sfx.set_filters(b[64]);
739 sfx
740}
741
742fn channel(b: u8) -> Option<u8> {
745 (b & 0x40 == 0).then_some(b & 0x3f)
746}
747
748fn decode_png_rgba(bytes: &[u8]) -> Result<(usize, usize, Vec<u8>)> {
756 if !bytes.starts_with(&PNG_SIG) {
757 bail!("not a png file");
758 }
759 let mut rest = &bytes[8..];
760 let (mut width, mut height) = (0usize, 0usize);
761 let mut idat = Vec::new();
762 let mut have_ihdr = false;
763 while rest.len() >= 12 {
764 let len = u32::from_be_bytes(rest[0..4].try_into().unwrap()) as usize;
765 let ctype = &rest[4..8];
766 if rest.len() < 12 + len {
767 bail!("truncated png chunk");
768 }
769 let data = &rest[8..8 + len];
770 match ctype {
771 b"IHDR" => {
772 if len < 13 {
773 bail!("malformed png header");
774 }
775 width = u32::from_be_bytes(data[0..4].try_into().unwrap()) as usize;
776 height = u32::from_be_bytes(data[4..8].try_into().unwrap()) as usize;
777 let (bit_depth, color_type, interlace) = (data[8], data[9], data[12]);
778 if bit_depth != 8 || color_type != 6 {
779 bail!("unsupported png: need 8-bit rgba (a pico-8 cart png is)");
780 }
781 if interlace != 0 {
782 bail!("interlaced png is not supported");
783 }
784 have_ihdr = true;
785 }
786 b"IDAT" => idat.extend_from_slice(data),
787 b"IEND" => break,
788 _ => {}
789 }
790 rest = &rest[12 + len..];
791 }
792 if !have_ihdr {
793 bail!("png has no header chunk");
794 }
795 let raw = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&idat, 64 * 1024 * 1024)
796 .map_err(|e| anyhow!("png image data is corrupted: {e:?}"))?;
797
798 const BPP: usize = 4;
799 let stride = width * BPP;
800 if raw.len() < height * (stride + 1) {
801 bail!("png image data is truncated");
802 }
803 let mut out = vec![0u8; height * stride];
804 for y in 0..height {
805 let filter = raw[y * (stride + 1)];
806 let line = &raw[y * (stride + 1) + 1..y * (stride + 1) + 1 + stride];
807 for i in 0..stride {
808 let a = if i >= BPP {
809 out[y * stride + i - BPP]
810 } else {
811 0
812 };
813 let b = if y > 0 { out[(y - 1) * stride + i] } else { 0 };
814 let c = if y > 0 && i >= BPP {
815 out[(y - 1) * stride + i - BPP]
816 } else {
817 0
818 };
819 out[y * stride + i] = match filter {
820 0 => line[i],
821 1 => line[i].wrapping_add(a),
822 2 => line[i].wrapping_add(b),
823 3 => line[i].wrapping_add(((a as u16 + b as u16) / 2) as u8),
824 4 => line[i].wrapping_add(paeth(a, b, c)),
825 f => bail!("unknown png filter type {f}"),
826 };
827 }
828 }
829 Ok((width, height, out))
830}
831
832fn paeth(a: u8, b: u8, c: u8) -> u8 {
834 let p = a as i32 + b as i32 - c as i32;
835 let (pa, pb, pc) = (
836 (p - a as i32).abs(),
837 (p - b as i32).abs(),
838 (p - c as i32).abs(),
839 );
840 if pa <= pb && pa <= pc {
841 a
842 } else if pb <= pc {
843 b
844 } else {
845 c
846 }
847}
848
849fn hex(c: char) -> Option<u8> {
854 c.to_digit(16).map(|d| d as u8)
855}
856
857fn hex_digits(s: &str) -> Vec<u8> {
859 s.chars().filter_map(hex).collect()
860}
861
862pub(crate) fn hex_bytes(s: &str) -> Vec<u8> {
864 hex_digits(s)
865 .chunks(2)
866 .filter(|c| c.len() == 2)
867 .map(|c| c[0] << 4 | c[1])
868 .collect()
869}
870
871pub(crate) fn bytes_to_hex(b: &[u8]) -> String {
873 use std::fmt::Write;
874 let mut s = String::with_capacity(b.len() * 2);
875 for &byte in b {
876 let _ = write!(s, "{byte:02x}");
877 }
878 s
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 fn sample_p8() -> String {
887 let mut s = String::from("pico-8 cartridge // http://www.pico-8.com\nversion 41\n");
888 s.push_str("__lua__\n");
889 s.push_str("function _draw()\n cls(1)\nend\n");
890 s.push_str("__gfx__\n");
892 let mut row0 = vec!['0'; 128];
893 row0[2] = 'a';
894 s.push_str(&row0.iter().collect::<String>());
895 s.push('\n');
896 let mut row1 = vec!['0'; 128];
897 row1[3] = '5';
898 s.push_str(&row1.iter().collect::<String>());
899 s.push('\n');
900 s.push_str("__gff__\n");
902 let mut gff = String::from("0380");
903 gff.push_str(&"00".repeat(254));
904 s.push_str(&gff);
905 s.push('\n');
906 s.push_str("__map__\n");
908 let mut map = String::from("002a");
909 map.push_str(&"00".repeat(126));
910 s.push_str(&map);
911 s.push('\n');
912 s.push_str("__sfx__\n");
914 let mut sfx = String::from("86100204"); sfx.push_str("21361"); sfx.push_str("10a50"); sfx.push_str(&"00000".repeat(30)); s.push_str(&sfx);
920 s.push('\n');
921 s.push_str("__music__\n");
923 s.push_str("01 01404040\n");
924 s
925 }
926
927 #[test]
928 fn parses_text_sections() {
929 let a = parse_text(&sample_p8()).unwrap();
930
931 assert_eq!(a.sprites.get(2, 0), 0xa);
932 assert_eq!(a.sprites.get(3, 1), 0x5);
933 assert_eq!(a.sprites.flags(0), 0x03);
934 assert_eq!(a.sprites.flags(1), 0x80);
935 assert_eq!(a.map.get(1, 0), 0x2a);
936
937 let n = a.sfx[0].notes[0];
938 assert_eq!((n.pitch, n.wave, n.volume, n.effect), (0x21, 3, 6, 1));
939 assert_eq!(n.instrument(), None, "a plain note is not a custom instr");
940 assert_eq!(a.sfx[0].speed, 0x10);
941 assert_eq!((a.sfx[0].loop_start, a.sfx[0].loop_end), (0x02, 0x04));
942 let f = &a.sfx[0];
944 assert!(f.noiz && f.buzz);
945 assert_eq!((f.detune, f.reverb, f.dampen), (1, 2, 1));
946
947 let n1 = a.sfx[0].notes[1];
949 assert_eq!(n1.instrument(), Some(2));
950 assert_eq!(n1.wave_index(), 2);
951
952 let m = &a.music[0];
953 assert!(m.loop_start && !m.loop_back && !m.stop_at_end);
954 assert_eq!(m.channels, [Some(1), None, None, None]);
955 }
956
957 #[test]
958 fn default_dir_name_strips_suffixes() {
959 assert_eq!(default_dir_name(Path::new("airwolf.p8")), "airwolf");
960 assert_eq!(default_dir_name(Path::new("celeste.p8.png")), "celeste");
961 assert_eq!(default_dir_name(Path::new("/a/b/jelpi.p8")), "jelpi");
962 assert_eq!(default_dir_name(Path::new("noext")), "noext");
963 }
964
965 #[test]
966 fn rejects_non_pico8_bytes() {
967 assert!(parse_bytes(b"just some text").is_err());
968 assert!(parse_bytes(&[0u8, 1, 2, 3]).is_err());
969 }
970
971 #[test]
974 fn parses_png_cart() {
975 let mut rom = vec![0u8; ROM_LEN];
977 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);
983 rom[0x3200] = (v & 0xff) as u8;
984 rom[0x3201] = (v >> 8) as u8;
985 rom[0x3200 + 64] = 0x1a; rom[0x3200 + 65] = 0x18; rom[0x3100] = 0x07;
989 rom[0x3100 + 2] = 0x80;
990
991 let png = build_pico8_png(&rom);
992 let a = parse_bytes(&png).unwrap();
993
994 assert_eq!(a.sprites.get(0, 0), 0x0);
995 assert_eq!(a.sprites.get(1, 0), 0xb);
996 assert_eq!(a.sprites.flags(0), 0x42);
997 assert_eq!(a.map.get(5, 0), 0x09);
998 assert_eq!(a.map.get(3, 32), 0x57);
1000 let n = a.sfx[0].notes[0];
1001 assert_eq!((n.pitch, n.volume, n.effect), (0x12, 5, 3));
1002 assert_eq!(n.instrument(), Some(2), "bit 15 marks a custom instrument");
1003 assert_eq!(a.sfx[0].speed, 0x18);
1004 assert!(a.sfx[0].noiz && !a.sfx[0].buzz);
1005 assert_eq!(
1006 (a.sfx[0].detune, a.sfx[0].reverb, a.sfx[0].dampen),
1007 (0, 1, 0)
1008 );
1009 assert_eq!(a.music[0].channels[0], Some(7));
1010 assert!(a.music[0].stop_at_end);
1011 }
1012
1013 fn build_pico8_png(rom: &[u8]) -> Vec<u8> {
1016 let (w, h) = (PICO8_PNG_W, PICO8_PNG_H);
1017 let mut rgba = vec![0u8; w * h * 4];
1018 for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
1019 let byte = rom.get(i).copied().unwrap_or(0);
1020 px[0] = byte >> 4 & 3; px[1] = byte >> 2 & 3; px[2] = byte & 3; px[3] = byte >> 6 & 3; }
1025 let mut raw = Vec::with_capacity(h * (1 + w * 4));
1026 for y in 0..h {
1027 raw.push(0);
1028 raw.extend_from_slice(&rgba[y * w * 4..(y + 1) * w * 4]);
1029 }
1030
1031 let mut png = PNG_SIG.to_vec();
1032 let mut ihdr = Vec::new();
1033 ihdr.extend((w as u32).to_be_bytes());
1034 ihdr.extend((h as u32).to_be_bytes());
1035 ihdr.extend([8, 6, 0, 0, 0]);
1036 write_chunk(&mut png, *b"IHDR", &ihdr);
1037 let idat = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
1038 write_chunk(&mut png, *b"IDAT", &idat);
1039 write_chunk(&mut png, *b"IEND", &[]);
1040 png
1041 }
1042
1043 fn write_chunk(out: &mut Vec<u8>, ctype: [u8; 4], data: &[u8]) {
1044 out.extend((data.len() as u32).to_be_bytes());
1045 out.extend(ctype);
1046 out.extend_from_slice(data);
1047 let mut h = crc32fast::Hasher::new();
1048 h.update(&ctype);
1049 h.update(data);
1050 out.extend(h.finalize().to_be_bytes());
1051 }
1052
1053 #[test]
1054 fn import_project_writes_assets() {
1055 let base = std::env::temp_dir().join(format!("pixel8_p8_import_{}", std::process::id()));
1056 let _ = std::fs::remove_dir_all(&base);
1057 let src = base.join("celeste.p8");
1058 std::fs::create_dir_all(&base).unwrap();
1059 std::fs::write(&src, sample_p8()).unwrap();
1060
1061 let dir = base.join("ported");
1062 let project = import_project(&src, &dir).unwrap();
1063
1064 assert_eq!(project.name, "ported");
1065 assert_eq!(project.assets.meta.name, "celeste");
1066 assert_eq!(project.assets.sprites.get(2, 0), 0xa);
1067 assert!(project.code.contains("Imported from PICO-8"));
1068 assert!(!dir.join("pico8.lua").exists());
1070
1071 std::fs::remove_dir_all(&base).unwrap();
1072 }
1073
1074 #[test]
1075 fn parse_ranges_singles_and_ranges() {
1076 assert_eq!(
1077 parse_index_ranges("0-3,5,8-9", 64).unwrap(),
1078 vec![0, 1, 2, 3, 5, 8, 9]
1079 );
1080 assert_eq!(parse_index_ranges("7", 64).unwrap(), vec![7]);
1081 assert_eq!(
1083 parse_index_ranges(" 3, 1 ,1, 2 ", 64).unwrap(),
1084 vec![1, 2, 3]
1085 );
1086 assert_eq!(parse_index_ranges("255", 256).unwrap(), vec![255]);
1088 }
1089
1090 #[test]
1091 fn parse_ranges_rejects_bad_input() {
1092 assert!(parse_index_ranges("", 64).is_err(), "empty string");
1093 assert!(parse_index_ranges("1,,2", 64).is_err(), "empty token");
1094 assert!(parse_index_ranges("64", 64).is_err(), "out of range");
1095 assert!(parse_index_ranges("5-3", 64).is_err(), "reversed range");
1096 assert!(parse_index_ranges("x", 64).is_err(), "non-numeric");
1097 assert!(
1098 parse_index_ranges("0-99", 64).is_err(),
1099 "range end out of bounds"
1100 );
1101 }
1102
1103 #[test]
1104 fn selection_parse_requires_one_kind() {
1105 assert!(Selection::parse(None, None, None).is_err());
1106 let s = Selection::parse(Some("0-2"), None, Some("3")).unwrap();
1107 assert_eq!(s.sprites, vec![0, 1, 2]);
1108 assert!(s.sfx.is_empty());
1109 assert_eq!(s.music, vec![3]);
1110 }
1111
1112 #[test]
1113 fn append_sprites_into_empty_lands_at_zero() {
1114 let mut src = Assets::default();
1115 src.sprites.set(0, 0, 7); src.sprites.set(8, 0, 9); src.sprites.flags[1] = 0x05;
1118 let mut dest = Assets::default();
1119 let sel = Selection {
1120 sprites: vec![0, 1],
1121 sfx: vec![],
1122 music: vec![],
1123 };
1124
1125 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1126
1127 assert_eq!((r.sprites.start, r.sprites.count), (0, 2));
1128 assert_eq!(dest.sprites.get(0, 0), 7);
1129 assert_eq!(dest.sprites.get(8, 0), 9);
1130 assert_eq!(dest.sprites.flags(1), 0x05);
1131 assert!(r.warnings.is_empty());
1132 }
1133
1134 #[test]
1135 fn append_sprites_after_last_used_slot() {
1136 let mut dest = Assets::default();
1137 dest.sprites.set(0, 0, 1); dest.sprites.flags[3] = 0x01; let mut src = Assets::default();
1140 src.sprites.set(0, 0, 0xc);
1141 let sel = Selection {
1142 sprites: vec![0],
1143 sfx: vec![],
1144 music: vec![],
1145 };
1146
1147 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1148
1149 assert_eq!(r.sprites.start, 4);
1151 assert_eq!(dest.sprites.get(32, 0), 0xc);
1153 assert_eq!(dest.sprites.get(0, 0), 1);
1155 }
1156
1157 #[test]
1158 fn append_music_remaps_imported_sfx_refs() {
1159 let mut src = Assets::default();
1160 src.sfx[5].notes[0].volume = 5;
1161 src.sfx[6].notes[0].volume = 5;
1162 src.music[0].channels = [Some(5), Some(6), None, None];
1163 let mut dest = Assets::default();
1164 let sel = Selection {
1165 sprites: vec![],
1166 sfx: vec![5, 6],
1167 music: vec![0],
1168 };
1169
1170 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1171
1172 assert_eq!((r.sfx.start, r.sfx.count), (0, 2));
1173 assert_eq!((r.music.start, r.music.count), (0, 1));
1174 assert_eq!(dest.music[0].channels, [Some(0), Some(1), None, None]);
1176 assert!(r.warnings.is_empty());
1177 }
1178
1179 #[test]
1180 fn append_music_keeps_and_warns_on_dangling_ref() {
1181 let mut src = Assets::default();
1182 src.sfx[5].notes[0].volume = 5;
1183 src.music[0].channels = [Some(5), Some(9), None, None];
1185 let mut dest = Assets::default();
1186 let sel = Selection {
1187 sprites: vec![],
1188 sfx: vec![5],
1189 music: vec![0],
1190 };
1191
1192 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1193
1194 assert_eq!(dest.music[0].channels, [Some(0), Some(9), None, None]);
1195 assert_eq!(r.warnings.len(), 1);
1196 assert!(
1197 r.warnings[0].contains('9'),
1198 "warning names the dangling slot"
1199 );
1200 }
1201
1202 #[test]
1203 fn append_custom_instrument_past_slot7_keeps_and_warns() {
1204 let mut src = Assets::default();
1205 src.sfx[2].notes[0].volume = 5; src.sfx[10].notes[0] = Note {
1207 pitch: 20,
1208 wave: NOTE_CUSTOM_FLAG | 2, volume: 5,
1210 effect: 0,
1211 };
1212 let mut dest = Assets::default();
1213 for i in 0..8 {
1215 dest.sfx[i].notes[0].volume = 1;
1216 }
1217 let sel = Selection {
1218 sprites: vec![],
1219 sfx: vec![2, 10],
1220 music: vec![],
1221 };
1222
1223 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1224
1225 assert_eq!(r.sfx.start, 8); assert_eq!(dest.sfx[9].notes[0].instrument(), Some(2));
1229 assert!(r.warnings.iter().any(|w| w.contains("custom instrument")));
1230 }
1231
1232 #[test]
1233 fn append_overflow_errors_and_leaves_dest_untouched() {
1234 let mut dest = Assets::default();
1235 dest.sfx[63].notes[0].volume = 1; let src = Assets::default();
1237 let sel = Selection {
1238 sprites: vec![],
1239 sfx: vec![0],
1240 music: vec![],
1241 };
1242
1243 let err = append_pico8_assets(&mut dest, &src, &sel).unwrap_err();
1244
1245 assert!(err.to_string().contains("room"), "got: {err}");
1246 assert_eq!(dest.sfx[63].notes[0].volume, 1);
1248 }
1249
1250 #[test]
1251 fn append_does_not_overwrite_custom_wave_instrument() {
1252 let mut dest = Assets::default();
1253 dest.sfx[3].custom_wave = Some(assets::CustomWave {
1255 samples: [0; SFX_LEN],
1256 bass: false,
1257 });
1258 let mut src = Assets::default();
1259 src.sfx[0].notes[0].volume = 5;
1260 let sel = Selection {
1261 sprites: vec![],
1262 sfx: vec![0],
1263 music: vec![],
1264 };
1265
1266 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1267
1268 assert_eq!(r.sfx.start, 4);
1270 assert!(dest.sfx[3].custom_wave.is_some(), "instrument preserved");
1271 }
1272
1273 #[test]
1274 fn append_does_not_overwrite_flow_only_music_pattern() {
1275 let mut dest = Assets::default();
1276 dest.music[2].stop_at_end = true;
1278 let mut src = Assets::default();
1279 src.music[0].channels[0] = Some(0);
1280 let sel = Selection {
1281 sprites: vec![],
1282 sfx: vec![],
1283 music: vec![0],
1284 };
1285
1286 let r = append_pico8_assets(&mut dest, &src, &sel).unwrap();
1287
1288 assert_eq!(r.music.start, 3, "import lands after the flow-only pattern");
1289 assert!(dest.music[2].stop_at_end, "flow pattern preserved");
1290 }
1291
1292 #[test]
1293 fn append_sprite_overflow_errors_and_keeps_dest() {
1294 let mut dest = Assets::default();
1295 dest.sprites.set(120, 120, 1); let src = Assets::default();
1297 let sel = Selection {
1298 sprites: vec![0],
1299 sfx: vec![],
1300 music: vec![],
1301 };
1302
1303 let err = append_pico8_assets(&mut dest, &src, &sel).unwrap_err();
1304
1305 assert!(err.to_string().contains("room"), "got: {err}");
1306 assert_eq!(dest.sprites.get(120, 120), 1); }
1308
1309 #[test]
1310 fn append_music_overflow_errors() {
1311 let mut dest = Assets::default();
1312 dest.music[63].channels[0] = Some(0); let src = Assets::default();
1314 let sel = Selection {
1315 sprites: vec![],
1316 sfx: vec![],
1317 music: vec![0],
1318 };
1319
1320 let err = append_pico8_assets(&mut dest, &src, &sel).unwrap_err();
1321 assert!(err.to_string().contains("room"), "got: {err}");
1322 }
1323
1324 #[test]
1325 fn hex_round_trips() {
1326 let b = [0x00u8, 0x08, 0xff, 0x10, 0xab];
1327 assert_eq!(hex_bytes(&bytes_to_hex(&b)), b);
1328 }
1329
1330 #[test]
1331 fn append_round_trips_through_a_project() {
1332 let base = std::env::temp_dir().join(format!("pixel8_append_{}", std::process::id()));
1333 let _ = std::fs::remove_dir_all(&base);
1334 let src = base.join("celeste.p8");
1335 std::fs::create_dir_all(&base).unwrap();
1336 std::fs::write(&src, sample_p8()).unwrap();
1337
1338 let dir = base.join("dest");
1340 let mut project = Project::create(&dir, "dest").unwrap();
1341 project.assets.sprites.set(0, 0, 4);
1342 project.save().unwrap();
1343
1344 let assets = parse_file(&src).unwrap();
1346 let sel = Selection {
1347 sprites: vec![0],
1348 sfx: vec![],
1349 music: vec![],
1350 };
1351 append_pico8_assets(&mut project.assets, &assets, &sel).unwrap();
1352 project.save().unwrap();
1353
1354 let reloaded = Project::load(&dir).unwrap();
1356 assert_eq!(reloaded.assets.sprites.get(10, 0), 0xa);
1358 assert_eq!(reloaded.assets.sprites.get(0, 0), 4); std::fs::remove_dir_all(&base).unwrap();
1361 }
1362}