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        .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
683/// Fill map rows 32..64 from PICO-8's shared region. PICO-8 aliases
684/// `0x1000..0x2000` between the bottom half of the sprite sheet and the
685/// bottom 32 rows of the map; a cart uses it for one or the other, with no
686/// flag saying which. Pixel8 de-aliases the two (it has a full 256-sprite
687/// sheet *and* a full 128x64 map), so we bring the region across both ways:
688/// the bytes already populate sprites 128..256, and here they populate the
689/// lower map too. The user keeps whichever their cart actually used and
690/// clears the other. `byte_at(off)` returns the byte at `0x1000 + off`.
691fn 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
699/// One PICO-8 music pattern from its four cart-memory bytes. The loop/stop
700/// flags ride in the high bit of the first three channel bytes.
701fn 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
715/// One PICO-8 SFX from its 68 cart-memory bytes: 32 notes of two bytes
716/// (little-endian: pitch 0-5, waveform 6-8, volume 9-11, effect 12-14,
717/// custom-instrument flag 15), then the filter/editor-mode byte, speed,
718/// loop-start and loop-end metadata.
719fn 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        // Bit 15 is PICO-8's custom-instrument flag; fold it into our wave
724        // nibble (bit 3) alongside the 3-bit waveform/instrument index.
725        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
744/// Decode one music channel byte: the low 6 bits are the SFX index; bit 6
745/// marks the channel silent. (Bit 7 carries pattern flags, handled apart.)
746fn channel(b: u8) -> Option<u8> {
747    (b & 0x40 == 0).then_some(b & 0x3f)
748}
749
750// ---------------------------------------------------------------------------
751// A small PNG decoder (8-bit RGBA, non-interlaced) for cart images
752// ---------------------------------------------------------------------------
753
754/// Decode an 8-bit RGBA, non-interlaced PNG to `(width, height, rgba)`.
755/// Just enough of the spec to read a PICO-8 cart image; richer PNGs are
756/// rejected with a clear message rather than mis-decoded.
757fn 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
834/// The PNG Paeth predictor.
835fn 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
851// ---------------------------------------------------------------------------
852// Hex helpers
853// ---------------------------------------------------------------------------
854
855fn hex(c: char) -> Option<u8> {
856    c.to_digit(16).map(|d| d as u8)
857}
858
859/// Every hex digit in `s`, as nibble values, skipping anything else.
860fn hex_digits(s: &str) -> Vec<u8> {
861    s.chars().filter_map(hex).collect()
862}
863
864/// Hex digits of `s` paired into bytes (most-significant nibble first).
865pub(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
873/// `b` as a lowercase hex string, two digits per byte (inverse of [`hex_bytes`]).
874pub(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    /// A minimal but complete text `.p8` exercising every asset section.
888    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        // gfx: set pixel (2,0)=a and (3,1)=5; rest zero.
893        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        // gff: sprite 0 flags = 0x03, sprite 1 = 0x80.
903        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        // map: tile (1,0)=2a.
909        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        // sfx 0: speed 0x10, loop 02..04, note0 pitch 21 wave 3 vol 6 eff 1.
915        s.push_str("__sfx__\n");
916        // filter byte 0x86 = noiz + buzz + detune 1 + reverb 2 + dampen 1.
917        let mut sfx = String::from("86100204"); // filters, speed, loop start, loop end
918        sfx.push_str("21361"); // note 0: pitch 21, wave 3, vol 6, eff 1
919        sfx.push_str("10a50"); // note 1: custom instrument 2 (nibble 0xa), vol 5
920        sfx.push_str(&"00000".repeat(30)); // notes 2..32 silent
921        s.push_str(&sfx);
922        s.push('\n');
923        // music 0: loop start flag, ch0=sfx1, others silent.
924        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        // Filter byte 0x86 decodes to every switch engaged.
945        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        // Note 1 is a custom instrument: index 2 with the custom flag set.
950        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    /// Build a PICO-8-style PNG from a ROM and round-trip it through the
974    /// PNG decoder + stegano extraction.
975    #[test]
976    fn parses_png_cart() {
977        // A ROM with a couple of distinctive asset bytes set.
978        let mut rom = vec![0u8; ROM_LEN];
979        rom[0] = 0xb0; // gfx byte 0: pixel(0,0)=0, pixel(1,0)=0xb
980        rom[0x3000] = 0x42; // sprite 0 flags
981        rom[0x2000 + 5] = 0x09; // map tile (5,0)
982        rom[0x1000 + 3] = 0x57; // shared region -> map row 32, col 3
983                                // sfx 0, note 0: pitch=0x12, custom instr 2, vol=5, eff=3.
984        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; // filters: noiz + reverb 1
988        rom[0x3200 + 65] = 0x18; // speed
989                                 // music 0: ch0 = sfx 7, stop flag on ch2.
990        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        // The shared region lands both in sprites 128.. and in the lower map.
1001        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    /// Encode a ROM into a 160x205 RGBA PNG the way PICO-8 does: two bits
1016    /// of each byte per A/R/G/B channel, filter-0 scanlines, zlib IDAT.
1017    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; // r
1023            px[1] = byte >> 2 & 3; // g
1024            px[2] = byte & 3; // b
1025            px[3] = byte >> 6 & 3; // a
1026        }
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        // Only assets are imported; no Lua is preserved.
1071        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        // Whitespace is tolerated; output is sorted and deduped.
1084        assert_eq!(
1085            parse_index_ranges(" 3, 1 ,1, 2 ", 64).unwrap(),
1086            vec![1, 2, 3]
1087        );
1088        // The max is exclusive: index 255 is the last valid sprite.
1089        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); // sprite 0, pixel (0,0).
1118        src.sprites.set(8, 0, 9); // sprite 1, pixel (0,0).
1119        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); // sprite 0 used by a pixel.
1140        dest.sprites.flags[3] = 0x01; // sprite 3 used by a flag only.
1141        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        // Highest used was sprite 3, so the import lands at sprite 4.
1152        assert_eq!(r.sprites.start, 4);
1153        // Sprite 4 sits at sheet (32, 0).
1154        assert_eq!(dest.sprites.get(32, 0), 0xc);
1155        // Earlier slots are untouched.
1156        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        // SFX 5 landed in slot 0 and 6 in slot 1; the channels follow.
1177        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        // Channel 1 references SFX 9, which is not in the selection.
1186        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; // a referenced instrument timbre.
1208        src.sfx[10].notes[0] = Note {
1209            pitch: 20,
1210            wave: NOTE_CUSTOM_FLAG | 2, // plays SFX 2 as a custom instrument.
1211            volume: 5,
1212            effect: 0,
1213        };
1214        let mut dest = Assets::default();
1215        // Fill SFX 0..8 so the import is forced to land at slot 8+.
1216        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); // SFX 2 -> slot 8, SFX 10 -> slot 9.
1228                                    // The custom-instrument ref can't point past slot 7,
1229                                    // so it is kept.
1230        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; // highest slot used -> no room.
1238        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        // Transactional: the destination is unchanged on error.
1249        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        // A silent custom-wave instrument at slot 3 (no audible notes).
1256        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        // The instrument at slot 3 counts as used, so the import lands at slot 4.
1271        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        // A pattern with no channels but a stop flag (a real song terminator).
1279        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); // sprite 255's block -> sheet is full.
1298        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); // unchanged.
1309    }
1310
1311    #[test]
1312    fn append_music_overflow_errors() {
1313        let mut dest = Assets::default();
1314        dest.music[63].channels[0] = Some(0); // pattern 63 used -> full.
1315        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        // Destination project with sprite 0 already used.
1341        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        // Append source sprite 0 (pixel (2,0) = 0xa per sample_p8).
1347        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        // Reload from disk and confirm the append persisted at sprite 1.
1357        let reloaded = Project::load(&dir).unwrap();
1358        // Sprite 1 sits at sheet (8, 0); source pixel (2,0) lands at (10, 0).
1359        assert_eq!(reloaded.assets.sprites.get(10, 0), 0xa);
1360        assert_eq!(reloaded.assets.sprites.get(0, 0), 4); // original kept.
1361
1362        std::fs::remove_dir_all(&base).unwrap();
1363    }
1364}