diff --git a/src/commands/build.rs b/src/commands/build.rs index a62ce14..bdd6031 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -7,6 +7,7 @@ use crate::file_names::*; use crate::fs::{collect_sizes, format_size}; use crate::images::convert_image; use crate::langs::build_bin; +use crate::palettes::{get_palette, parse_palettes, Palettes}; use crate::vfs::init_vfs; use anyhow::{bail, Context}; use chrono::Datelike; @@ -72,13 +73,15 @@ pub fn cmd_build(vfs: PathBuf, args: &BuildArgs) -> anyhow::Result<()> { if !args.no_tip { show_tip(); } + let palettes = parse_palettes(config.palettes.as_ref()).context("parse palettes")?; let old_sizes = collect_sizes(&config.rom_path); + let meta = write_meta(&config).context("write metadata file")?; build_bin(&config, args).context("build binary")?; remove_old_files(&config.rom_path).context("remove old files")?; if let Some(files) = &config.files { for (name, file_config) in files { - convert_file(name, &config, file_config) + convert_file(name, &config, &palettes, file_config) .with_context(|| format!("convert \"{name}\""))?; } } @@ -172,36 +175,43 @@ fn remove_old_files(root: &Path) -> anyhow::Result<()> { } /// Get a file from config, convert it if needed, and write into the ROM. -fn convert_file(name: &str, config: &Config, file_config: &FileConfig) -> anyhow::Result<()> { +fn convert_file( + name: &str, + config: &Config, + palettes: &Palettes, + file_config: &FileConfig, +) -> anyhow::Result<()> { if name == SIG || name == META || name == HASH || name == KEY { bail!("ROM file name \"{name}\" is reserved"); } - let output_path = config.rom_path.join(name); + let out_path = config.rom_path.join(name); // The input path is defined in the config // and should be resolved relative to the project root. - let input_path = &config.root_path.join(&file_config.path); - download_file(input_path, file_config).context("download file")?; + let in_path = &config.root_path.join(&file_config.path); + download_file(in_path, file_config).context("download file")?; if file_config.copy { - fs::copy(input_path, &output_path)?; + fs::copy(in_path, &out_path)?; return Ok(()); } - let Some(extension) = input_path.extension() else { - let file_name = input_path.to_str().unwrap().to_string(); + let Some(extension) = in_path.extension() else { + let file_name = in_path.to_str().unwrap().to_string(); bail!("cannot detect extension for {file_name}"); }; let Some(extension) = extension.to_str() else { bail!("cannot convert file extension to string"); }; + // TODO(@orsinium): fail if palette is set for a non-image file. match extension { "png" => { - convert_image(input_path, &output_path)?; + let palette = get_palette(file_config.palette.as_deref(), palettes)?; + convert_image(in_path, &out_path, palette)?; } "wav" => { - convert_wav(input_path, &output_path)?; + convert_wav(in_path, &out_path)?; } // firefly formats for fonts and images "fff" | "ffi" | "ffz" => { - fs::copy(input_path, &output_path)?; + fs::copy(in_path, &out_path)?; } _ => bail!("unknown file extension: {extension}"), } diff --git a/src/config.rs b/src/config.rs index ff48b22..10bde2d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,9 @@ pub struct Config { /// Mapping of board IDs to boards. pub boards: Option>, + /// Mapping of board IDs to boards. + pub palettes: Option>>, + /// Path to the project root. #[serde(skip)] pub root_path: PathBuf, @@ -109,6 +112,9 @@ pub struct FileConfig { /// The file hash to validate when downloading the file. pub sha256: Option, + /// The name of the color palette in the list of palettes. + pub palette: Option, + /// If the file should be copied as-is, without any processing. #[serde(default)] pub copy: bool, diff --git a/src/images.rs b/src/images.rs index 82081f7..0a90d55 100644 --- a/src/images.rs +++ b/src/images.rs @@ -1,54 +1,32 @@ +use crate::palettes::{Color, Palette}; use anyhow::{bail, Context, Result}; -use image::{Pixel, Rgb, Rgba, RgbaImage}; +use image::{Pixel, Rgba, RgbaImage}; use std::fs::File; use std::io::Write; use std::path::Path; -type Color = Option>; - -static DEFAULT_PALETTE: &[Option>] = &[ - // https://lospec.com/palette-list/sweetie-16 - // https://github.com/nesbox/TIC-80/wiki/Palette - Some(Rgb([0x1a, 0x1c, 0x2c])), // black - Some(Rgb([0x5d, 0x27, 0x5d])), // purple - Some(Rgb([0xb1, 0x3e, 0x53])), // red - Some(Rgb([0xef, 0x7d, 0x57])), // orange - Some(Rgb([0xff, 0xcd, 0x75])), // yellow - Some(Rgb([0xa7, 0xf0, 0x70])), // light green - Some(Rgb([0x38, 0xb7, 0x64])), // green - Some(Rgb([0x25, 0x71, 0x79])), // dark green - Some(Rgb([0x29, 0x36, 0x6f])), // dark blue - Some(Rgb([0x3b, 0x5d, 0xc9])), // blue - Some(Rgb([0x41, 0xa6, 0xf6])), // light blue - Some(Rgb([0x73, 0xef, 0xf7])), // cyan - Some(Rgb([0xf4, 0xf4, 0xf4])), // white - Some(Rgb([0x94, 0xb0, 0xc2])), // light gray - Some(Rgb([0x56, 0x6c, 0x86])), // gray - Some(Rgb([0x33, 0x3c, 0x57])), // dark gray -]; - -pub fn convert_image(input_path: &Path, output_path: &Path) -> Result<()> { - let file = image::ImageReader::open(input_path).context("open image file")?; +pub fn convert_image(in_path: &Path, out_path: &Path, sys_pal: &Palette) -> Result<()> { + let file = image::ImageReader::open(in_path).context("open image file")?; let img = file.decode().context("decode image")?; let img = img.to_rgba8(); if img.width() % 8 != 0 { bail!("image width must be divisible by 8"); } - let palette = make_palette(&img).context("detect colors used in the image")?; - let mut out = File::create(output_path).context("create output path")?; + let mut img_pal = make_palette(&img, sys_pal).context("detect colors used in the image")?; + let mut out = File::create(out_path).context("create output path")?; write_u8(&mut out, 0x21)?; - let colors = palette.len(); + let colors = img_pal.len(); if colors <= 2 { - let palette = extend_palette(palette, 2); - write_image::<1, 8>(out, &img, &palette).context("write 1BPP image") + extend_palette(&mut img_pal, sys_pal, 2); + write_image::<1, 8>(out, &img, &img_pal, sys_pal).context("write 1BPP image") } else if colors <= 4 { - let palette = extend_palette(palette, 4); - write_image::<2, 4>(out, &img, &palette).context("write 1BPP image") + extend_palette(&mut img_pal, sys_pal, 4); + write_image::<2, 4>(out, &img, &img_pal, sys_pal).context("write 1BPP image") } else if colors <= 16 { - let palette = extend_palette(palette, 16); - write_image::<4, 2>(out, &img, &palette).context("write 1BPP image") + extend_palette(&mut img_pal, sys_pal, 16); + write_image::<4, 2>(out, &img, &img_pal, sys_pal).context("write 1BPP image") } else { - let has_transparency = palette.iter().any(Option::is_none); + let has_transparency = img_pal.iter().any(Option::is_none); if has_transparency && colors == 17 { bail!("cannot use all 16 colors with transparency, remove one color"); } @@ -59,22 +37,23 @@ pub fn convert_image(input_path: &Path, output_path: &Path) -> Result<()> { fn write_image( mut out: File, img: &RgbaImage, - palette: &[Color], + img_pal: &[Color], + sys_pal: &Palette, ) -> Result<()> { write_u8(&mut out, BPP)?; // BPP let Ok(width) = u16::try_from(img.width()) else { bail!("the image is too big") }; write_u16(&mut out, width)?; // image width - let transparent = pick_transparent(palette)?; + let transparent = pick_transparent(img_pal, sys_pal)?; write_u8(&mut out, transparent)?; // transparent color // palette swaps let mut byte = 0; - debug_assert!(palette.len() == 2 || palette.len() == 4 || palette.len() == 16); - for (i, color) in palette.iter().enumerate() { + debug_assert!(img_pal.len() == 2 || img_pal.len() == 4 || img_pal.len() == 16); + for (i, color) in img_pal.iter().enumerate() { let index = match color { - Some(color) => find_color_default(*color), + Some(color) => find_color(sys_pal, Some(*color)), None => transparent, }; byte = (byte << 4) | index; @@ -87,7 +66,7 @@ fn write_image( let mut byte: u8 = 0; for (i, pixel) in img.pixels().enumerate() { let color = convert_color(*pixel); - let raw_color = find_color(palette, color); + let raw_color = find_color(img_pal, color); byte = (byte << BPP) | raw_color; if (i + 1) % PPB == 0 { write_u8(&mut out, byte)?; @@ -97,14 +76,14 @@ fn write_image( } /// Detect all colors used in the image -fn make_palette(img: &RgbaImage) -> Result> { +fn make_palette(img: &RgbaImage, sys_pal: &Palette) -> Result> { let mut palette = Vec::new(); for (x, y, pixel) in img.enumerate_pixels() { let color = convert_color(*pixel); if !palette.contains(&color) { - if color.is_some() && !DEFAULT_PALETTE.contains(&color) { + if color.is_some() && !sys_pal.contains(&color) { bail!( - "found a color not present in the default color palette: {} (at x={x}, y={y})", + "found a color not present in the color palette: {} (at x={x}, y={y})", format_color(color), ); } @@ -112,19 +91,18 @@ fn make_palette(img: &RgbaImage) -> Result> { } } palette.sort_by_key(|c| match c { - Some(c) => find_color_default(*c), + Some(c) => find_color(sys_pal, Some(*c)), None => 20, }); Ok(palette) } /// Add empty colors at the end of the palette to match the BPP size. -fn extend_palette(mut palette: Vec, size: usize) -> Vec { - let n = size - palette.len(); +fn extend_palette(img_pal: &mut Vec, sys_pal: &Palette, size: usize) { + let n = size - img_pal.len(); for _ in 0..n { - palette.push(DEFAULT_PALETTE[0]); + img_pal.push(sys_pal[0]); } - palette } fn write_u8(f: &mut File, v: u8) -> std::io::Result<()> { @@ -135,11 +113,6 @@ fn write_u16(f: &mut File, v: u16) -> std::io::Result<()> { f.write_all(&v.to_le_bytes()) } -/// Find the index of the given color in the default palette. -fn find_color_default(c: Rgb) -> u8 { - find_color(DEFAULT_PALETTE, Some(c)) -} - /// Find the index of the given color in the given palette. fn find_color(palette: &[Color], c: Color) -> u8 { for (color, i) in palette.iter().zip(0u8..) { @@ -147,7 +120,7 @@ fn find_color(palette: &[Color], c: Color) -> u8 { return i; } } - panic!("color not in the default palette") + panic!("color not in the palette") } /// Make human-friendly hex representation of the color code. @@ -174,28 +147,30 @@ const fn is_transparent(c: Rgba) -> bool { } /// Pick the color to be used to represent transparency -fn pick_transparent(palette: &[Color]) -> Result { - if palette.iter().all(Option::is_some) { +fn pick_transparent(img_pal: &[Color], sys_pal: &Palette) -> Result { + if img_pal.iter().all(Option::is_some) { // no transparency needed return Ok(17); } - for (color, i) in DEFAULT_PALETTE.iter().zip(0u8..) { - if !palette.contains(color) { + for (color, i) in sys_pal.iter().zip(0u8..) { + if !img_pal.contains(color) { return Ok(i); } } - if palette.len() > 16 { + if img_pal.len() > 16 { bail!("the image cannot contain more than 16 colors") } - if palette.len() == 16 { + if img_pal.len() == 16 { bail!("an image cannot contain all 16 colors and transparency") } - bail!("image contains colors not from the default palette") + bail!("image contains colors not from the palette") } #[cfg(test)] mod tests { use super::*; + use crate::palettes::SWEETIE16; + use image::Rgb; #[test] fn test_format_color() { @@ -205,14 +180,15 @@ mod tests { #[test] fn test_pick_transparent() { - let c0 = DEFAULT_PALETTE[0]; - let c1 = DEFAULT_PALETTE[1]; - let c2 = DEFAULT_PALETTE[2]; - let c3 = DEFAULT_PALETTE[3]; - assert_eq!(pick_transparent(&[c0, c1]).unwrap(), 17); - assert_eq!(pick_transparent(&[c0, c1, None]).unwrap(), 2); - assert_eq!(pick_transparent(&[c0, None, c1]).unwrap(), 2); - assert_eq!(pick_transparent(&[c1, c0, None]).unwrap(), 2); - assert_eq!(pick_transparent(&[c0, c1, c2, c3, None]).unwrap(), 4); + let pal = SWEETIE16; + let c0 = pal[0]; + let c1 = pal[1]; + let c2 = pal[2]; + let c3 = pal[3]; + assert_eq!(pick_transparent(&[c0, c1], pal).unwrap(), 17); + assert_eq!(pick_transparent(&[c0, c1, None], pal).unwrap(), 2); + assert_eq!(pick_transparent(&[c0, None, c1], pal).unwrap(), 2); + assert_eq!(pick_transparent(&[c1, c0, None], pal).unwrap(), 2); + assert_eq!(pick_transparent(&[c0, c1, c2, c3, None], pal).unwrap(), 4); } } diff --git a/src/main.rs b/src/main.rs index ac20989..fef2127 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod repl_helper; mod vfs; mod wasm; +mod palettes; #[cfg(test)] mod test_helpers; diff --git a/src/palettes.rs b/src/palettes.rs new file mode 100644 index 0000000..37e3058 --- /dev/null +++ b/src/palettes.rs @@ -0,0 +1,144 @@ +use anyhow::{bail, Context, Result}; +use image::Rgb; +use std::collections::HashMap; + +pub type Color = Option>; +pub type Palette = [Color; 16]; +pub type Palettes = HashMap; +type RawPalette = HashMap; + +/// The default color palette (SWEETIE-16). +/// +/// +/// +pub static SWEETIE16: &[Option>; 16] = &[ + Some(Rgb([0x1a, 0x1c, 0x2c])), // black + Some(Rgb([0x5d, 0x27, 0x5d])), // purple + Some(Rgb([0xb1, 0x3e, 0x53])), // red + Some(Rgb([0xef, 0x7d, 0x57])), // orange + Some(Rgb([0xff, 0xcd, 0x75])), // yellow + Some(Rgb([0xa7, 0xf0, 0x70])), // light green + Some(Rgb([0x38, 0xb7, 0x64])), // green + Some(Rgb([0x25, 0x71, 0x79])), // dark green + Some(Rgb([0x29, 0x36, 0x6f])), // dark blue + Some(Rgb([0x3b, 0x5d, 0xc9])), // blue + Some(Rgb([0x41, 0xa6, 0xf6])), // light blue + Some(Rgb([0x73, 0xef, 0xf7])), // cyan + Some(Rgb([0xf4, 0xf4, 0xf4])), // white + Some(Rgb([0x94, 0xb0, 0xc2])), // light gray + Some(Rgb([0x56, 0x6c, 0x86])), // gray + Some(Rgb([0x33, 0x3c, 0x57])), // dark gray +]; + +pub fn parse_palettes(raws: Option<&HashMap>) -> Result { + let mut palettes = Palettes::new(); + let Some(raws) = raws else { + return Ok(palettes); + }; + for (name, raw) in raws { + let palette = parse_palette(raw).context(format!("parse {name} palette"))?; + palettes.insert(name.clone(), palette); + } + Ok(palettes) +} + +fn parse_palette(raw: &RawPalette) -> Result { + let len = raw.len(); + if len > 16 { + bail!("too many colors") + } + if len < 2 { + bail!("too few colors") + } + if raw.get("0").is_some() { + bail!("color IDs must start at 1"); + } + let len = u16::try_from(len).unwrap(); + + let mut palette: Palette = Palette::default(); + for id in 1u16..=len { + let Some(raw_color) = raw.get(&id.to_string()) else { + bail!("color IDs must be consequentive but ID {id} is missed"); + }; + let color = parse_color(*raw_color)?; + let idx = usize::from(id - 1); + palette[idx] = color; + } + Ok(palette) +} + +#[expect(clippy::cast_possible_truncation)] +fn parse_color(raw: u32) -> Result { + if raw > 0xff_ff_ff { + bail!("the color is out of range") + } + let r = (raw >> 16) as u8; + let g = (raw >> 8) as u8; + let b = raw as u8; + Ok(Some(Rgb([r, g, b]))) +} + +pub fn get_palette<'a>(name: Option<&str>, palettes: &'a Palettes) -> Result<&'a Palette> { + let Some(name) = name else { + return Ok(SWEETIE16); + }; + let Some(palette) = palettes.get(name) else { + return get_builtin_palette(name); + }; + Ok(palette) +} + +pub fn get_builtin_palette(name: &str) -> Result<&'static Palette> { + let name = name.to_ascii_lowercase(); + let palette = match name.as_str() { + "sweetie16" | "sweetie-16" | "default" => SWEETIE16, + _ => bail!("palette {name} not found"), + }; + Ok(palette) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_palettes() { + let mut p = RawPalette::new(); + p.insert("1".to_string(), 0x_ff_00_00); + p.insert("2".to_string(), 0x_00_ff_00); + p.insert("3".to_string(), 0x_00_00_ff); + let mut ps = HashMap::new(); + ps.insert("rgb".to_string(), p); + let res = parse_palettes(Some(&ps)).unwrap(); + assert_eq!(res.len(), 1); + let exp: Palette = [ + Some(Rgb([0xff, 0x00, 0x00])), + Some(Rgb([0x00, 0xff, 0x00])), + Some(Rgb([0x00, 0x00, 0xff])), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ]; + assert_eq!(*res.get("rgb").unwrap(), exp); + } + + #[test] + fn test_get_palette() { + let mut p = Palettes::new(); + p.insert("sup".to_string(), *SWEETIE16); + assert_eq!(get_palette(None, &p).unwrap(), SWEETIE16); + assert_eq!(get_palette(Some("sup"), &p).unwrap(), SWEETIE16); + assert_eq!(get_palette(Some("sweetie16"), &p).unwrap(), SWEETIE16); + assert!(get_palette(Some("foobar"), &p).is_err()); + } +}