Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}\""))?;
}
}
Expand Down Expand Up @@ -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}"),
}
Expand Down
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ pub struct Config {
/// Mapping of board IDs to boards.
pub boards: Option<HashMap<String, BoardConfig>>,

/// Mapping of board IDs to boards.
pub palettes: Option<HashMap<String, HashMap<String, u32>>>,

/// Path to the project root.
#[serde(skip)]
pub root_path: PathBuf,
Expand Down Expand Up @@ -109,6 +112,9 @@ pub struct FileConfig {
/// The file hash to validate when downloading the file.
pub sha256: Option<String>,

/// The name of the color palette in the list of palettes.
pub palette: Option<String>,

/// If the file should be copied as-is, without any processing.
#[serde(default)]
pub copy: bool,
Expand Down
120 changes: 48 additions & 72 deletions src/images.rs
Original file line number Diff line number Diff line change
@@ -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<Rgb<u8>>;

static DEFAULT_PALETTE: &[Option<Rgb<u8>>] = &[
// 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");
}
Expand All @@ -59,22 +37,23 @@ pub fn convert_image(input_path: &Path, output_path: &Path) -> Result<()> {
fn write_image<const BPP: u8, const PPB: usize>(
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;
Expand All @@ -87,7 +66,7 @@ fn write_image<const BPP: u8, const PPB: usize>(
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)?;
Expand All @@ -97,34 +76,33 @@ fn write_image<const BPP: u8, const PPB: usize>(
}

/// Detect all colors used in the image
fn make_palette(img: &RgbaImage) -> Result<Vec<Color>> {
fn make_palette(img: &RgbaImage, sys_pal: &Palette) -> Result<Vec<Color>> {
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),
);
}
palette.push(color);
}
}
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<Color>, size: usize) -> Vec<Color> {
let n = size - palette.len();
fn extend_palette(img_pal: &mut Vec<Color>, 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<()> {
Expand All @@ -135,19 +113,14 @@ 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>) -> 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..) {
if *color == c {
return i;
}
}
panic!("color not in the default palette")
panic!("color not in the palette")
}

/// Make human-friendly hex representation of the color code.
Expand All @@ -174,28 +147,30 @@ const fn is_transparent(c: Rgba<u8>) -> bool {
}

/// Pick the color to be used to represent transparency
fn pick_transparent(palette: &[Color]) -> Result<u8> {
if palette.iter().all(Option::is_some) {
fn pick_transparent(img_pal: &[Color], sys_pal: &Palette) -> Result<u8> {
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() {
Expand All @@ -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);
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod repl_helper;
mod vfs;
mod wasm;

mod palettes;
#[cfg(test)]
mod test_helpers;

Expand Down
Loading