neser 1.2.0

NESER - Nintendo Emulation Systems Engine (Rust). Desktop and WebAssembly frontends.
Documentation
//! Preset NES system palettes (composite NTSC).
//!
//! The NES PPU does not output RGB directly; it generates an analog composite
//! signal whose decoded colors depend on the television set and decoding
//! parameters. As a result there is no single "correct" RGB palette, and
//! different emulators ship a variety of preset palettes. This module defines a
//! small set of well-known presets as static 64-entry RGB lookup tables and a
//! [`NesPalette`] enum to select and cycle between them.
//!
//! Sources for the bundled tables:
//! - `Default`: the emulator's original built-in palette (unchanged behavior).
//! - `NesDev`: NESdev Wiki reference palette `2C02G_wiki.pal`, generated with
//!   Pally/palgen-persune. <https://www.nesdev.org/wiki/PPU_palettes>
//! - `Smooth`: "Smooth V2 (FBX)" palette by FirebrandX.
//! - `Classic`: "Classic (FBX)" palette by FirebrandX.
//! - `CompositeDirect`: "Composite Direct (FBX)" palette by FirebrandX.

/// A selectable preset NES system palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NesPalette {
    /// The emulator's original built-in palette.
    #[default]
    Default,
    /// NESdev Wiki reference palette (2C02G).
    NesDev,
    /// "Smooth V2 (FBX)" palette by FirebrandX.
    Smooth,
    /// "Classic (FBX)" palette by FirebrandX.
    Classic,
    /// "Composite Direct (FBX)" palette by FirebrandX.
    CompositeDirect,
}

impl NesPalette {
    /// All presets in cycle order.
    pub const ALL: [NesPalette; 5] = [
        NesPalette::Default,
        NesPalette::NesDev,
        NesPalette::Smooth,
        NesPalette::Classic,
        NesPalette::CompositeDirect,
    ];

    /// Returns the 64-entry RGB lookup table for this preset.
    pub fn table(self) -> &'static [(u8, u8, u8); 64] {
        match self {
            NesPalette::Default => &PALETTE_DEFAULT,
            NesPalette::NesDev => &PALETTE_NESDEV,
            NesPalette::Smooth => &PALETTE_SMOOTH,
            NesPalette::Classic => &PALETTE_CLASSIC,
            NesPalette::CompositeDirect => &PALETTE_COMPOSITE_DIRECT,
        }
    }

    /// Human-readable display name (used in toast messages).
    pub fn display_name(self) -> &'static str {
        match self {
            NesPalette::Default => "Default",
            NesPalette::NesDev => "NesDev",
            NesPalette::Smooth => "Smooth",
            NesPalette::Classic => "Classic",
            NesPalette::CompositeDirect => "Composite Direct",
        }
    }

    /// Lowercase config/CLI identifier for this preset.
    pub fn config_id(self) -> &'static str {
        match self {
            NesPalette::Default => "default",
            NesPalette::NesDev => "nesdev",
            NesPalette::Smooth => "smooth",
            NesPalette::Classic => "classic",
            NesPalette::CompositeDirect => "composite-direct",
        }
    }

    /// Parses a config/CLI identifier (case-insensitive) into a preset.
    ///
    /// Returns `None` for unknown identifiers.
    pub fn from_config_id(id: &str) -> Option<NesPalette> {
        let id = id.trim().to_ascii_lowercase();
        NesPalette::ALL.into_iter().find(|p| p.config_id() == id)
    }

    /// Returns the next preset in cycle order, wrapping around.
    pub fn next(self) -> NesPalette {
        let idx = NesPalette::ALL.iter().position(|&p| p == self).unwrap_or(0);
        NesPalette::ALL[(idx + 1) % NesPalette::ALL.len()]
    }
}

/// The emulator's original built-in palette.
#[rustfmt::skip]
pub const PALETTE_DEFAULT: [(u8, u8, u8); 64] = [
    (0x54, 0x54, 0x54), (0x00, 0x1E, 0x74), (0x08, 0x10, 0x90), (0x30, 0x00, 0x88),
    (0x44, 0x00, 0x64), (0x5C, 0x00, 0x30), (0x54, 0x04, 0x00), (0x3C, 0x18, 0x00),
    (0x20, 0x2A, 0x00), (0x08, 0x3A, 0x00), (0x00, 0x40, 0x00), (0x00, 0x3C, 0x00),
    (0x00, 0x32, 0x3C), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0x98, 0x96, 0x98), (0x08, 0x4C, 0xC4), (0x30, 0x32, 0xEC), (0x5C, 0x1E, 0xE4),
    (0x88, 0x14, 0xB0), (0xA0, 0x14, 0x64), (0x98, 0x22, 0x20), (0x78, 0x3C, 0x00),
    (0x54, 0x5A, 0x00), (0x28, 0x72, 0x00), (0x08, 0x7C, 0x00), (0x00, 0x76, 0x28),
    (0x00, 0x66, 0x78), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xEC, 0xEE, 0xEC), (0x4C, 0x9A, 0xEC), (0x78, 0x7C, 0xEC), (0xB0, 0x62, 0xEC),
    (0xE4, 0x54, 0xEC), (0xEC, 0x58, 0xB4), (0xEC, 0x6A, 0x64), (0xD4, 0x88, 0x20),
    (0xA0, 0xAA, 0x00), (0x74, 0xC4, 0x00), (0x4C, 0xD0, 0x20), (0x38, 0xCC, 0x6C),
    (0x38, 0xB4, 0xCC), (0x3C, 0x3C, 0x3C), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xEC, 0xEE, 0xEC), (0xA8, 0xCC, 0xEC), (0xBC, 0xBC, 0xEC), (0xD4, 0xB2, 0xEC),
    (0xEC, 0xAE, 0xEC), (0xEC, 0xAE, 0xD4), (0xEC, 0xB4, 0xB0), (0xE4, 0xC4, 0x90),
    (0xCC, 0xD2, 0x78), (0xB4, 0xDE, 0x78), (0xA8, 0xE2, 0x90), (0x98, 0xE2, 0xB4),
    (0xA0, 0xD6, 0xE4), (0xA0, 0xA2, 0xA0), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
];

/// NESdev Wiki reference palette (2C02G).
#[rustfmt::skip]
pub const PALETTE_NESDEV: [(u8, u8, u8); 64] = [
    (0x62, 0x62, 0x62), (0x00, 0x1C, 0x95), (0x19, 0x04, 0xAC), (0x42, 0x00, 0x9D),
    (0x61, 0x00, 0x6B), (0x6E, 0x00, 0x25), (0x65, 0x05, 0x00), (0x49, 0x1E, 0x00),
    (0x22, 0x37, 0x00), (0x00, 0x49, 0x00), (0x00, 0x4F, 0x00), (0x00, 0x48, 0x16),
    (0x00, 0x35, 0x5E), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xAB, 0xAB, 0xAB), (0x0C, 0x4E, 0xDB), (0x3D, 0x2E, 0xFF), (0x71, 0x15, 0xF3),
    (0x9B, 0x0B, 0xB9), (0xB0, 0x12, 0x62), (0xA9, 0x27, 0x04), (0x89, 0x46, 0x00),
    (0x57, 0x66, 0x00), (0x23, 0x7F, 0x00), (0x00, 0x89, 0x00), (0x00, 0x83, 0x32),
    (0x00, 0x6D, 0x90), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF), (0x57, 0xA5, 0xFF), (0x82, 0x87, 0xFF), (0xB4, 0x6D, 0xFF),
    (0xDF, 0x60, 0xFF), (0xF8, 0x63, 0xC6), (0xF8, 0x74, 0x6D), (0xDE, 0x90, 0x20),
    (0xB3, 0xAE, 0x00), (0x81, 0xC8, 0x00), (0x56, 0xD5, 0x22), (0x3D, 0xD3, 0x6F),
    (0x3E, 0xC1, 0xC8), (0x4E, 0x4E, 0x4E), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF), (0xBE, 0xE0, 0xFF), (0xCD, 0xD4, 0xFF), (0xE0, 0xCA, 0xFF),
    (0xF1, 0xC4, 0xFF), (0xFC, 0xC4, 0xEF), (0xFD, 0xCA, 0xCE), (0xF5, 0xD4, 0xAF),
    (0xE6, 0xDF, 0x9C), (0xD3, 0xE9, 0x9A), (0xC2, 0xEF, 0xA8), (0xB7, 0xEF, 0xC4),
    (0xB6, 0xEA, 0xE5), (0xB8, 0xB8, 0xB8), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
];

/// "Smooth V2 (FBX)" palette by FirebrandX.
#[rustfmt::skip]
pub const PALETTE_SMOOTH: [(u8, u8, u8); 64] = [
    (0x6A, 0x6A, 0x6A), (0x00, 0x14, 0x8F), (0x1E, 0x02, 0x9B), (0x3F, 0x00, 0x8A),
    (0x60, 0x00, 0x60), (0x66, 0x00, 0x17), (0x57, 0x0D, 0x00), (0x3C, 0x1F, 0x00),
    (0x1B, 0x33, 0x00), (0x00, 0x42, 0x00), (0x00, 0x45, 0x00), (0x00, 0x3C, 0x1F),
    (0x00, 0x31, 0x5C), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xB9, 0xB9, 0xB9), (0x0F, 0x4B, 0xD4), (0x41, 0x2D, 0xEB), (0x6C, 0x1D, 0xD9),
    (0x9C, 0x17, 0xAB), (0xA7, 0x1A, 0x4D), (0x99, 0x32, 0x00), (0x7C, 0x4A, 0x00),
    (0x54, 0x64, 0x00), (0x1A, 0x78, 0x00), (0x00, 0x7F, 0x00), (0x00, 0x76, 0x3E),
    (0x00, 0x67, 0x8F), (0x01, 0x01, 0x01), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF), (0x68, 0xA6, 0xFF), (0x8C, 0x9C, 0xFF), (0xB5, 0x86, 0xFF),
    (0xD9, 0x75, 0xFD), (0xE3, 0x77, 0xB9), (0xE5, 0x8D, 0x68), (0xD4, 0x9D, 0x29),
    (0xB3, 0xAF, 0x0C), (0x7B, 0xC2, 0x11), (0x55, 0xCA, 0x47), (0x46, 0xCB, 0x81),
    (0x47, 0xC1, 0xC5), (0x4A, 0x4A, 0x4A), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF), (0xCC, 0xEA, 0xFF), (0xDD, 0xDE, 0xFF), (0xEC, 0xDA, 0xFF),
    (0xF8, 0xD7, 0xFE), (0xFC, 0xD6, 0xF5), (0xFD, 0xDB, 0xCF), (0xF9, 0xE7, 0xB5),
    (0xF1, 0xF0, 0xAA), (0xDA, 0xFA, 0xA9), (0xC9, 0xFF, 0xBC), (0xC3, 0xFB, 0xD7),
    (0xC4, 0xF6, 0xF6), (0xBE, 0xBE, 0xBE), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
];

/// "Classic (FBX)" palette by FirebrandX.
#[rustfmt::skip]
pub const PALETTE_CLASSIC: [(u8, u8, u8); 64] = [
    (0x61, 0x61, 0x61), (0x00, 0x00, 0x88), (0x1F, 0x0D, 0x99), (0x37, 0x13, 0x79),
    (0x56, 0x12, 0x60), (0x5D, 0x00, 0x10), (0x52, 0x0E, 0x00), (0x3A, 0x23, 0x08),
    (0x21, 0x35, 0x0C), (0x0D, 0x41, 0x0E), (0x17, 0x44, 0x17), (0x00, 0x3A, 0x1F),
    (0x00, 0x2F, 0x57), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xAA, 0xAA, 0xAA), (0x0D, 0x4D, 0xC4), (0x4B, 0x24, 0xDE), (0x69, 0x12, 0xCF),
    (0x90, 0x14, 0xAD), (0x9D, 0x1C, 0x48), (0x92, 0x34, 0x04), (0x73, 0x50, 0x05),
    (0x5D, 0x69, 0x13), (0x16, 0x7A, 0x11), (0x13, 0x80, 0x08), (0x12, 0x76, 0x49),
    (0x1C, 0x66, 0x91), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFC, 0xFC, 0xFC), (0x63, 0x9A, 0xFC), (0x8A, 0x7E, 0xFC), (0xB0, 0x6A, 0xFC),
    (0xDD, 0x6D, 0xF2), (0xE7, 0x71, 0xAB), (0xE3, 0x86, 0x58), (0xCC, 0x9E, 0x22),
    (0xA8, 0xB1, 0x00), (0x72, 0xC1, 0x00), (0x5A, 0xCD, 0x4E), (0x34, 0xC2, 0x8E),
    (0x4F, 0xBE, 0xCE), (0x42, 0x42, 0x42), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFC, 0xFC, 0xFC), (0xBE, 0xD4, 0xFC), (0xCA, 0xCA, 0xFC), (0xD9, 0xC4, 0xFC),
    (0xEC, 0xC1, 0xFC), (0xFA, 0xC3, 0xE7), (0xF7, 0xCE, 0xC3), (0xE2, 0xCD, 0xA7),
    (0xDA, 0xDB, 0x9C), (0xC8, 0xE3, 0x9E), (0xBF, 0xE5, 0xB8), (0xB2, 0xEB, 0xC8),
    (0xB7, 0xE5, 0xEB), (0xAC, 0xAC, 0xAC), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
];

/// "Composite Direct (FBX)" palette by FirebrandX.
#[rustfmt::skip]
pub const PALETTE_COMPOSITE_DIRECT: [(u8, u8, u8); 64] = [
    (0x65, 0x65, 0x65), (0x00, 0x12, 0x7D), (0x18, 0x00, 0x8E), (0x36, 0x00, 0x82),
    (0x56, 0x00, 0x5D), (0x5A, 0x00, 0x18), (0x4F, 0x05, 0x00), (0x38, 0x19, 0x00),
    (0x1D, 0x31, 0x00), (0x00, 0x3D, 0x00), (0x00, 0x41, 0x00), (0x00, 0x3B, 0x17),
    (0x00, 0x2E, 0x55), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xAF, 0xAF, 0xAF), (0x19, 0x4E, 0xC8), (0x47, 0x2F, 0xE3), (0x6B, 0x1F, 0xD7),
    (0x93, 0x1B, 0xAE), (0x9E, 0x1A, 0x5E), (0x99, 0x32, 0x00), (0x7B, 0x4B, 0x00),
    (0x5B, 0x67, 0x00), (0x26, 0x7A, 0x00), (0x00, 0x82, 0x00), (0x00, 0x7A, 0x3E),
    (0x00, 0x6E, 0x8A), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF), (0x64, 0xA9, 0xFF), (0x8E, 0x89, 0xFF), (0xB6, 0x76, 0xFF),
    (0xE0, 0x6F, 0xFF), (0xEF, 0x6C, 0xC4), (0xF0, 0x80, 0x6A), (0xD8, 0x98, 0x2C),
    (0xB9, 0xB4, 0x0A), (0x83, 0xCB, 0x0C), (0x5B, 0xD6, 0x3F), (0x4A, 0xD1, 0x7E),
    (0x4D, 0xC7, 0xCB), (0x4C, 0x4C, 0x4C), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF), (0xC7, 0xE5, 0xFF), (0xD9, 0xD9, 0xFF), (0xE9, 0xD1, 0xFF),
    (0xF9, 0xCE, 0xFF), (0xFF, 0xCC, 0xF1), (0xFF, 0xD4, 0xCB), (0xF8, 0xDF, 0xB1),
    (0xED, 0xEA, 0xA4), (0xD6, 0xF4, 0xA4), (0xC5, 0xF8, 0xB8), (0xBE, 0xF6, 0xD3),
    (0xBF, 0xF1, 0xF1), (0xB9, 0xB9, 0xB9), (0x00, 0x00, 0x00), (0x00, 0x00, 0x00),
];

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_the_default_variant() {
        assert_eq!(NesPalette::default(), NesPalette::Default);
    }

    #[test]
    fn all_contains_every_preset_once() {
        assert_eq!(NesPalette::ALL.len(), 5);
        for p in NesPalette::ALL {
            assert_eq!(NesPalette::ALL.iter().filter(|&&q| q == p).count(), 1);
        }
    }

    #[test]
    fn every_table_has_64_entries() {
        for p in NesPalette::ALL {
            assert_eq!(p.table().len(), 64, "{} has wrong length", p.display_name());
        }
    }

    #[test]
    fn config_ids_round_trip_case_insensitively() {
        for p in NesPalette::ALL {
            let id = p.config_id();
            assert_eq!(NesPalette::from_config_id(id), Some(p));
            assert_eq!(NesPalette::from_config_id(&id.to_uppercase()), Some(p));
        }
    }

    #[test]
    fn from_config_id_trims_whitespace() {
        assert_eq!(
            NesPalette::from_config_id("  smooth  "),
            Some(NesPalette::Smooth)
        );
    }

    #[test]
    fn from_config_id_rejects_unknown() {
        assert_eq!(NesPalette::from_config_id("invalid_name"), None);
        assert_eq!(NesPalette::from_config_id(""), None);
    }

    #[test]
    fn next_cycles_through_all_and_wraps() {
        assert_eq!(NesPalette::Default.next(), NesPalette::NesDev);
        assert_eq!(NesPalette::NesDev.next(), NesPalette::Smooth);
        assert_eq!(NesPalette::Smooth.next(), NesPalette::Classic);
        assert_eq!(NesPalette::Classic.next(), NesPalette::CompositeDirect);
        assert_eq!(NesPalette::CompositeDirect.next(), NesPalette::Default);
    }

    #[test]
    fn presets_render_visibly_different_colors() {
        // Color $00 (gray) differs between every preset, confirming distinctness.
        let grays: Vec<(u8, u8, u8)> = NesPalette::ALL.iter().map(|p| p.table()[0]).collect();
        for i in 0..grays.len() {
            for j in (i + 1)..grays.len() {
                assert_ne!(grays[i], grays[j], "presets {i} and {j} share color $00");
            }
        }
    }

    #[test]
    fn default_table_matches_original_anchor_colors() {
        // Regression guard: the Default preset must preserve the historical values.
        assert_eq!(PALETTE_DEFAULT[0x00], (0x54, 0x54, 0x54));
        assert_eq!(PALETTE_DEFAULT[0x21], (0x4C, 0x9A, 0xEC));
        assert_eq!(PALETTE_DEFAULT[0x30], (0xEC, 0xEE, 0xEC));
    }

    #[test]
    fn sourced_tables_match_known_anchor_colors() {
        assert_eq!(PALETTE_NESDEV[0x00], (0x62, 0x62, 0x62));
        assert_eq!(PALETTE_SMOOTH[0x00], (0x6A, 0x6A, 0x6A));
        assert_eq!(PALETTE_CLASSIC[0x00], (0x61, 0x61, 0x61));
        assert_eq!(PALETTE_COMPOSITE_DIRECT[0x00], (0x65, 0x65, 0x65));
    }
}