Skip to main content

pixel8_runtime/
pico8.rs

1//! Importing PICO-8 cartridge assets.
2//!
3//! Pixel8 is a loving homage to [PICO-8](https://www.lexaloffle.com/pico-8.php):
4//! it borrows the very same 16-color palette, the same eight chip-tune
5//! waveforms and per-step effects, and the same 128x128 sprite sheet and
6//! 16-wide sprite layout. That overlap means a PICO-8 cart's *assets* —
7//! graphics, sprite flags, map, sound effects and music — transfer into a
8//! Pixel8 project almost one-to-one. This module does exactly that.
9//!
10//! Only the assets are imported. PICO-8 games are written in Lua and Pixel8
11//! games in Rust, so the cart's code is ignored entirely: the new project
12//! gets a stub `src/lib.rs` to build against, and the game logic is yours to
13//! write in Rust against the imported art and audio.
14//!
15//! Two input formats are understood:
16//!
17//! - **`.p8`** — the plain-text cartridge: labelled `__gfx__`, `__gff__`, `__label__`, `__map__`,
18//!   `__sfx__` and `__music__` sections of hex (the `__lua__` section is skipped).
19//! - **`.p8.png`** — the PNG cartridge: the game's 32 KiB ROM hidden two bits at a time in the low
20//!   bits of each pixel's A/R/G/B channels. The image is decoded, the ROM reassembled, and the same
21//!   fixed memory map PICO-8 uses (`0x0000` gfx, `0x2000` map, `0x3000` flags, `0x3100` music,
22//!   `0x3200` sfx) is read out.
23
24use 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
40/// PICO-8 cart image dimensions, fixed by the format.
41const PICO8_PNG_W: usize = 160;
42const PICO8_PNG_H: usize = 205;
43
44/// Size of the addressable cart ROM (gfx + map + flags + music + sfx).
45const ROM_LEN: usize = 0x4300;
46/// Rows the explicit PICO-8 map covers: the top 32 rows. The bottom 32 rows
47/// alias the shared sprite memory (`0x1000..0x2000`); we bring that region
48/// across as map rows 32..64 too — see [`fill_shared_map`].
49const PICO8_MAP_ROWS: usize = 32;
50/// Start of PICO-8's shared region: the bottom half of the sprite sheet,
51/// which doubles as the bottom 32 rows of the map.
52const SHARED_BASE: usize = 0x1000;
53/// Bytes per SFX in cart memory: 32 notes x 2 + 4 metadata bytes.
54const SFX_MEM_LEN: usize = 68;
55
56/// Stub `src/lib.rs` scaffolded for an imported cart. The assets are real;
57/// the game logic is the user's to write in Rust.
58pub 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/// Which source assets to append into a destination cart. Indices are the
83/// source PICO-8 cart's own indices (sprite 0..256, SFX/music 0..64).
84#[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    /// Build a selection from the range strings as typed on the command line,
93    /// e.g. `Selection::parse(Some("0-15,32"), Some("0-3"), None)`. At least
94    /// one kind must be given, or this is an error. Each string is parsed with
95    /// inclusive ranges and validated against its kind's maximum.
96    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/// Where one kind's appended items landed in the destination.
118#[derive(Debug, Clone, Copy, Default)]
119pub struct Placement {
120    /// First destination slot written (meaningful only when `count > 0`).
121    pub start: usize,
122    /// Number of items appended.
123    pub count: usize,
124}
125
126impl Placement {
127    /// A human line like `"12 sprites at slots 16 to 27"`, or `None` if nothing
128    /// of this kind was appended.
129    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/// The outcome of an append: where each kind landed, plus any warnings (e.g.
142/// references left pointing at slots that were not part of the selection).
143#[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    /// One line per kind that had anything appended.
153    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
165/// Append the selected `src` assets into `dest`, after `dest`'s last used slot
166/// of each kind. Imported audio cross-references (music channels and SFX
167/// custom-instrument notes) are remapped to the slots their targets landed in;
168/// references to slots that were not selected are left as-is and reported as
169/// warnings. `dest` is only mutated on success (a capacity or validation error
170/// leaves it untouched).
171pub fn append_pico8_assets(dest: &mut Assets, src: &Assets, sel: &Selection) -> Result<Report> {
172    // Guard against out-of-range indices so a hand-built selection can't panic
173    // on an indexing operation below (sprite indices are u8, always in range).
174    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    // Build into a clone; commit only after everything succeeds.
182    let mut out = dest.clone();
183    let mut warnings = Vec::new();
184
185    // --- Sprites: copy the 8x8 block and the flag byte. No references. ---
186    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    // --- SFX: copy, then remap each copy's custom-instrument note refs. ---
204    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    // Source SFX index -> destination slot, for remapping references.
214    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    // --- Music: copy, then remap each channel's SFX reference. ---
233    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
262/// The first free sprite slot: one past the highest used sprite, or 0 if none
263/// is used. A sprite is "used" when its 8x8 block has any non-zero pixel or its
264/// flag byte is non-zero.
265fn 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
273/// True when sprite `n` has any non-zero pixel or a non-zero flag byte.
274fn 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
284/// The first free SFX slot: one past the highest non-empty or custom-wave SFX, or 0.
285pub(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
293/// The first free music slot: one past the highest non-empty or flow-control pattern, or 0.
294fn 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
305/// Copy sprite `src_n`'s 8x8 block and flag byte into `dst_n`.
306fn 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
320/// Remap custom-instrument note refs in `notes` through `sfx_map` (source SFX
321/// slot to destination slot). A note whose instrument landed outside slots 0-7,
322/// or was not among the mapped SFX, is left as-is and a warning is pushed.
323/// `label` names the SFX in warnings (e.g. `"SFX 3"`).
324pub(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            // A custom instrument can only be addressed in slots 0..8.
336            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
349/// Remap a music pattern's channel SFX refs through `sfx_map`. A channel whose
350/// SFX was not among the mapped SFX is left as-is and a warning is pushed.
351pub(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
369/// Parse a comma-separated list of indices and inclusive ranges (e.g.
370/// `"0-15,32,40-43"`) into a sorted, deduped list, validating every value is
371/// below `max`.
372fn 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
396/// Parse one index, requiring it to be a number below `max`.
397fn 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
408/// Read a PICO-8 cart's assets from a file, auto-detecting `.p8` text vs
409/// `.p8.png`.
410pub fn parse_file(path: &Path) -> Result<Assets> {
411    let bytes = std::fs::read(path)?;
412    parse_bytes(&bytes)
413}
414
415/// Read a PICO-8 cart's assets from raw bytes, auto-detecting the format.
416pub 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
429/// Create a new Pixel8 project from a PICO-8 cart's assets.
430///
431/// The project's crate name comes from the target directory (like `new`);
432/// the cart title comes from the source file. Imported assets are written to
433/// `assets.pixel8.json` and a stub `src/lib.rs` is scaffolded.
434pub 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
452/// Human-readable cart title from a source path: the file name with the
453/// `.png` and `.p8` suffixes peeled off (`celeste.p8.png` -> `celeste`).
454fn 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
461/// Default project directory name to import a cart into when none is given:
462/// the cart's name with its suffixes peeled off (`airwolf.p8` -> `airwolf`),
463/// falling back to `imported`.
464pub fn default_dir_name(src: &Path) -> String {
465    cart_title(src).unwrap_or_else(|| "imported".into())
466}
467
468// ---------------------------------------------------------------------------
469// Text .p8 parsing
470// ---------------------------------------------------------------------------
471
472fn 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            // Everything else (including the ignored "lua" section) is skipped.
497            _ => {}
498        }
499    }
500
501    // gfx: one hex digit per pixel, row-major.
502    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    // gff: one byte (two hex digits) per sprite.
511    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    // map: one byte per tile; the text section is the top 32 rows.
520    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    // Bring the shared region across as the bottom map rows as well. In the
527    // text format it lives in the bottom 64 gfx rows we just parsed; read it
528    // back as packed bytes (two pixels each, low nibble first).
529    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    // label: 128x128 hex screenshot, one digit per pixel.
537    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    // sfx: 168 hex per line — 4 metadata bytes then 32 notes of 5 hex each.
550    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        // The first metadata byte packs the editor mode and filter switches.
556        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                // The waveform hex digit is the full nibble: bit 3 flags a
569                // custom instrument, bits 0-2 the index. Keep it intact.
570                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    // music: a flag byte then four channel bytes, e.g. "00 41424344".
587    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
614/// Recognize an exact `__name__` section header line.
615fn 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
620// ---------------------------------------------------------------------------
621// PNG .p8.png parsing
622// ---------------------------------------------------------------------------
623
624fn parse_png(bytes: &[u8]) -> Result<Assets> {
625    let rom = rom_from_png(bytes)?;
626    let mut assets = Assets::default();
627
628    // gfx 0x0000..0x2000: two pixels per byte, low nibble is the left pixel.
629    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    // map 0x2000..0x3000: top 32 rows, one byte per tile.
637    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    // The shared region 0x1000..0x2000 doubles as map rows 32..64.
643    fill_shared_map(&mut assets.map, |off| rom[SHARED_BASE + off]);
644    // gff 0x3000..0x3100: one flag byte per sprite.
645    assets
646        .sprites
647        .flags
648        .copy_from_slice(&rom[0x3000..0x3000 + SPRITE_COUNT]);
649    // music 0x3100..0x3200: four bytes per pattern.
650    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    // sfx 0x3200..0x4300: 68 bytes per slot.
655    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
664/// Reassemble the cart ROM from a PICO-8 PNG: two bits per channel, A/R/G/B.
665fn 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
681/// Fill map rows 32..64 from PICO-8's shared region. PICO-8 aliases
682/// `0x1000..0x2000` between the bottom half of the sprite sheet and the
683/// bottom 32 rows of the map; a cart uses it for one or the other, with no
684/// flag saying which. Pixel8 de-aliases the two (it has a full 256-sprite
685/// sheet *and* a full 128x64 map), so we bring the region across both ways:
686/// the bytes already populate sprites 128..256, and here they populate the
687/// lower map too. The user keeps whichever their cart actually used and
688/// clears the other. `byte_at(off)` returns the byte at `0x1000 + off`.
689fn 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
697/// One PICO-8 music pattern from its four cart-memory bytes. The loop/stop
698/// flags ride in the high bit of the first three channel bytes.
699fn 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
713/// One PICO-8 SFX from its 68 cart-memory bytes: 32 notes of two bytes
714/// (little-endian: pitch 0-5, waveform 6-8, volume 9-11, effect 12-14,
715/// custom-instrument flag 15), then the filter/editor-mode byte, speed,
716/// loop-start and loop-end metadata.
717fn 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        // Bit 15 is PICO-8's custom-instrument flag; fold it into our wave
722        // nibble (bit 3) alongside the 3-bit waveform/instrument index.
723        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
742/// Decode one music channel byte: the low 6 bits are the SFX index; bit 6
743/// marks the channel silent. (Bit 7 carries pattern flags, handled apart.)
744fn channel(b: u8) -> Option<u8> {
745    (b & 0x40 == 0).then_some(b & 0x3f)
746}
747
748// ---------------------------------------------------------------------------
749// A small PNG decoder (8-bit RGBA, non-interlaced) for cart images
750// ---------------------------------------------------------------------------
751
752/// Decode an 8-bit RGBA, non-interlaced PNG to `(width, height, rgba)`.
753/// Just enough of the spec to read a PICO-8 cart image; richer PNGs are
754/// rejected with a clear message rather than mis-decoded.
755fn 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
832/// The PNG Paeth predictor.
833fn 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
849// ---------------------------------------------------------------------------
850// Hex helpers
851// ---------------------------------------------------------------------------
852
853fn hex(c: char) -> Option<u8> {
854    c.to_digit(16).map(|d| d as u8)
855}
856
857/// Every hex digit in `s`, as nibble values, skipping anything else.
858fn hex_digits(s: &str) -> Vec<u8> {
859    s.chars().filter_map(hex).collect()
860}
861
862/// Hex digits of `s` paired into bytes (most-significant nibble first).
863pub(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
871/// `b` as a lowercase hex string, two digits per byte (inverse of [`hex_bytes`]).
872pub(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    /// A minimal but complete text `.p8` exercising every asset section.
886    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        // gfx: set pixel (2,0)=a and (3,1)=5; rest zero.
891        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        // gff: sprite 0 flags = 0x03, sprite 1 = 0x80.
901        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        // map: tile (1,0)=2a.
907        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        // sfx 0: speed 0x10, loop 02..04, note0 pitch 21 wave 3 vol 6 eff 1.
913        s.push_str("__sfx__\n");
914        // filter byte 0x86 = noiz + buzz + detune 1 + reverb 2 + dampen 1.
915        let mut sfx = String::from("86100204"); // filters, speed, loop start, loop end
916        sfx.push_str("21361"); // note 0: pitch 21, wave 3, vol 6, eff 1
917        sfx.push_str("10a50"); // note 1: custom instrument 2 (nibble 0xa), vol 5
918        sfx.push_str(&"00000".repeat(30)); // notes 2..32 silent
919        s.push_str(&sfx);
920        s.push('\n');
921        // music 0: loop start flag, ch0=sfx1, others silent.
922        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        // Filter byte 0x86 decodes to every switch engaged.
943        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        // Note 1 is a custom instrument: index 2 with the custom flag set.
948        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    /// Build a PICO-8-style PNG from a ROM and round-trip it through the
972    /// PNG decoder + stegano extraction.
973    #[test]
974    fn parses_png_cart() {
975        // A ROM with a couple of distinctive asset bytes set.
976        let mut rom = vec![0u8; ROM_LEN];
977        rom[0] = 0xb0; // gfx byte 0: pixel(0,0)=0, pixel(1,0)=0xb
978        rom[0x3000] = 0x42; // sprite 0 flags
979        rom[0x2000 + 5] = 0x09; // map tile (5,0)
980        rom[0x1000 + 3] = 0x57; // shared region -> map row 32, col 3
981                                // sfx 0, note 0: pitch=0x12, custom instr 2, vol=5, eff=3.
982        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; // filters: noiz + reverb 1
986        rom[0x3200 + 65] = 0x18; // speed
987                                 // music 0: ch0 = sfx 7, stop flag on ch2.
988        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        // The shared region lands both in sprites 128.. and in the lower map.
999        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    /// Encode a ROM into a 160x205 RGBA PNG the way PICO-8 does: two bits
1014    /// of each byte per A/R/G/B channel, filter-0 scanlines, zlib IDAT.
1015    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; // r
1021            px[1] = byte >> 2 & 3; // g
1022            px[2] = byte & 3; // b
1023            px[3] = byte >> 6 & 3; // a
1024        }
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        // Only assets are imported; no Lua is preserved.
1069        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        // Whitespace is tolerated; output is sorted and deduped.
1082        assert_eq!(
1083            parse_index_ranges(" 3, 1 ,1, 2 ", 64).unwrap(),
1084            vec![1, 2, 3]
1085        );
1086        // The max is exclusive: index 255 is the last valid sprite.
1087        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); // sprite 0, pixel (0,0).
1116        src.sprites.set(8, 0, 9); // sprite 1, pixel (0,0).
1117        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); // sprite 0 used by a pixel.
1138        dest.sprites.flags[3] = 0x01; // sprite 3 used by a flag only.
1139        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        // Highest used was sprite 3, so the import lands at sprite 4.
1150        assert_eq!(r.sprites.start, 4);
1151        // Sprite 4 sits at sheet (32, 0).
1152        assert_eq!(dest.sprites.get(32, 0), 0xc);
1153        // Earlier slots are untouched.
1154        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        // SFX 5 landed in slot 0 and 6 in slot 1; the channels follow.
1175        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        // Channel 1 references SFX 9, which is not in the selection.
1184        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; // a referenced instrument timbre.
1206        src.sfx[10].notes[0] = Note {
1207            pitch: 20,
1208            wave: NOTE_CUSTOM_FLAG | 2, // plays SFX 2 as a custom instrument.
1209            volume: 5,
1210            effect: 0,
1211        };
1212        let mut dest = Assets::default();
1213        // Fill SFX 0..8 so the import is forced to land at slot 8+.
1214        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); // SFX 2 -> slot 8, SFX 10 -> slot 9.
1226                                    // The custom-instrument ref can't point past slot 7,
1227                                    // so it is kept.
1228        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; // highest slot used -> no room.
1236        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        // Transactional: the destination is unchanged on error.
1247        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        // A silent custom-wave instrument at slot 3 (no audible notes).
1254        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        // The instrument at slot 3 counts as used, so the import lands at slot 4.
1269        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        // A pattern with no channels but a stop flag (a real song terminator).
1277        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); // sprite 255's block -> sheet is full.
1296        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); // unchanged.
1307    }
1308
1309    #[test]
1310    fn append_music_overflow_errors() {
1311        let mut dest = Assets::default();
1312        dest.music[63].channels[0] = Some(0); // pattern 63 used -> full.
1313        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        // Destination project with sprite 0 already used.
1339        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        // Append source sprite 0 (pixel (2,0) = 0xa per sample_p8).
1345        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        // Reload from disk and confirm the append persisted at sprite 1.
1355        let reloaded = Project::load(&dir).unwrap();
1356        // Sprite 1 sits at sheet (8, 0); source pixel (2,0) lands at (10, 0).
1357        assert_eq!(reloaded.assets.sprites.get(10, 0), 0xa);
1358        assert_eq!(reloaded.assets.sprites.get(0, 0), 4); // original kept.
1359
1360        std::fs::remove_dir_all(&base).unwrap();
1361    }
1362}