Skip to main content

rustynes_ppu/
palette_gen.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the generated NES palette follows Bisqwit's documented method and the ares `fc/ppu/color.cpp` colour integration (ares: BSD-2-Clause / Apache-2.0). See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! Generated NTSC base palette (v2.1.2 "Fathom" F1.4).
6//!
7//! The hand-authored [`crate::NES_PALETTE`] is one artist's calibration of a
8//! Sony PVM reference. This module instead *synthesizes* the 64-entry base
9//! palette from a model of the 2C02's composite-video output, following the
10//! Bisqwit composite-palette method published on the nesdev wiki: for each of
11//! the 64 colors,
12//! integrate the PPU's two-level chroma square wave over the 12 subcarrier
13//! phases of one pixel, demodulate to YIQ, and convert to RGB through the FCC
14//! matrix with a gamma correction. The result is deterministic, parameterized
15//! (saturation / hue / contrast / brightness / gamma), and — because every
16//! transcendental goes through `libm` rather than `std` — **byte-identical on
17//! every target** (x86 / aarch64 / wasm / `thumbv7em`).
18//!
19//! ## Where this sits in the pipeline (determinism boundary)
20//!
21//! This function produces a 64-entry `[[u8; 3]; 64]` base, exactly the shape a
22//! loaded `.pal` file yields. The frontend feeds it to
23//! `Nes::set_custom_palette(Some(base))`, and the PPU applies the *same*
24//! [`crate::palette::build_rgba_lut_from_base`] emphasis model it uses for the
25//! hand palette and any `.pal` — so there is **no new emphasis path** and the
26//! generated palette is a drop-in alternative base. It is **off by default**:
27//! the shipped build keeps [`crate::NES_PALETTE`], so the default-build
28//! framebuffer golden vectors (and `AccuracyCoin`) are unchanged. Selecting the
29//! generated palette changes framebuffer output and is therefore gated in the
30//! frontend behind an explicit palette-source choice with a deliberate visual
31//! re-bless (F1.4 / F2.2 plan, v2.0.3 precedent).
32//!
33//! ## Model reference
34//!
35//! The waveform constants (the eight composite voltage levels, the
36//! sync/black/white references, and the FCC YIQ→RGB matrix) are from Bisqwit's
37//! canonical NES palette generator as published on the nesdev wiki ("NTSC
38//! video") and cross-checked against ares as a behavioral oracle. The `hue`
39//! parameter
40//! is a global tint in subcarrier-phase units (each unit = 30°); grays are
41//! hue-independent because a constant signal integrates to zero chroma.
42
43// The pedantic `suboptimal_flops` lint suggests `mul_add` for the `a*b + c`
44// spots in the integration + YIQ→RGB matrix. We deliberately keep plain
45// mul-then-add: `mul_add` fuses to a single rounding whose result can differ
46// from the two-rounding form, and this palette feeds a committed cross-target
47// golden snapshot — determinism beats the micro-optimization. Mirrors the APU
48// crate's identical allow (`rustynes-apu/src/lib.rs`).
49#![allow(clippy::suboptimal_flops)]
50
51use libm::{cos, pow, round, sin};
52
53/// Core-math constant: π in radians, from `core::f64::consts`. Named locally so
54/// the phase-angle integration reads cleanly; the whole synthesizer stays
55/// `no_std` (the trig itself goes through `libm`, not `std`).
56const PI: f64 = core::f64::consts::PI;
57
58/// The eight composite signal voltage levels the 2C02 emits, relative to the
59/// sync tip. Indices `0..4` are the "signal low" half of the chroma square
60/// wave for luma levels `0..3`; indices `4..8` are the "signal high" half.
61/// (Bisqwit / nesdev "NTSC video".)
62const LEVELS: [f64; 8] = [
63    0.350, 0.518, 0.962, 1.550, // signal low  (luma level 0..3)
64    1.094, 1.506, 1.962, 1.962, // signal high (luma level 0..3)
65];
66
67/// Black reference voltage (the composite level that maps to RGB 0).
68const BLACK: f64 = 0.518;
69/// White reference voltage (the composite level that maps to full RGB).
70const WHITE: f64 = 1.962;
71/// Neutral display gamma — the [`NtscPaletteParams::default`] value and the
72/// fallback [`fcc_channel`] uses when a config-supplied `gamma` is non-finite or
73/// non-positive (which would make `2.2 / gamma` blow up).
74const DEFAULT_GAMMA: f64 = 1.8;
75
76/// Tunable parameters for [`generate_base_palette`].
77///
78/// All are pure inputs to a deterministic function; the same params always
79/// yield the same 64-entry base. [`Self::default`] is the neutral calibration.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct NtscPaletteParams {
82    /// Chroma gain. `1.0` is neutral; higher is more saturated, `0.0` is
83    /// grayscale.
84    pub saturation: f64,
85    /// Global hue rotation, in subcarrier-phase units (1 unit = 30°). `0.0` is
86    /// the standard orientation. Grays are unaffected.
87    pub hue: f64,
88    /// Luma contrast about mid-gray. `1.0` is neutral.
89    pub contrast: f64,
90    /// Overall luma gain. `1.0` is neutral.
91    pub brightness: f64,
92    /// Display gamma used for the `f^(2.2/gamma)` correction. `2.2` is a
93    /// no-op; values below `2.2` darken the mid-tones (CRT-like). Default
94    /// `1.8` matches the common Bisqwit-generator look on sRGB displays.
95    pub gamma: f64,
96}
97
98impl Default for NtscPaletteParams {
99    fn default() -> Self {
100        Self {
101            saturation: 1.0,
102            hue: 0.0,
103            contrast: 1.0,
104            brightness: 1.0,
105            gamma: DEFAULT_GAMMA,
106        }
107    }
108}
109
110/// Return `true` when the chroma square wave for `color` is in its "high" state
111/// at subcarrier phase `p` (0..12). This is the phase generator that gives each
112/// of the 12 hues its position on the color wheel; the `+ 8` aligns hue index 1
113/// to the standard orientation (Bisqwit / nesdev). All operands are small and
114/// non-negative, so the arithmetic stays in `usize`.
115#[inline]
116const fn wave_high(p: usize, color: usize) -> bool {
117    ((color + p + 8) % 12) < 6
118}
119
120/// Synthesize the 64-entry RGB888 base palette from `params`.
121///
122/// The output is a drop-in replacement for [`crate::NES_PALETTE`] (emphasis is
123/// **not** baked in here — the PPU's existing `build_rgba_lut_from_base` applies
124/// it). Deterministic and `no_std`; see the module docs for the model.
125#[must_use]
126pub fn generate_base_palette(params: &NtscPaletteParams) -> [[u8; 3]; 64] {
127    let mut out = [[0u8; 3]; 64];
128    for (pixel, slot) in out.iter_mut().enumerate() {
129        *slot = generate_one(pixel, params);
130    }
131    out
132}
133
134/// Synthesize a single color (`pixel` = the 6-bit NES index, `0..=63`).
135fn generate_one(pixel: usize, params: &NtscPaletteParams) -> [u8; 3] {
136    let color = pixel & 0x0F; // chroma / hue nibble (0..15)
137    // Colors $0E/$0F are "forbidden" blacks; clamp their luma level to 1 so the
138    // math is well-defined (they resolve to black regardless).
139    let level = if color < 0x0E { (pixel >> 4) & 3 } else { 1 }; // 0..3
140
141    // The two composite voltage levels this color alternates between:
142    //   lo (wave in its low state), hi (wave in its high state).
143    // Color $0 (gray) forces the low state up to the high level (no chroma);
144    // colors $0D..$0F have no high level (their high state stays low → dark).
145    // `level + 4*flag` is provably 0..7, indexing `LEVELS` in-bounds.
146    let lo = LEVELS[level + 4 * usize::from(color == 0x00)];
147    let hi = LEVELS[level + 4 * usize::from(color < 0x0D)];
148
149    // Integrate over the 12 subcarrier phases of one pixel, demodulating to
150    // YIQ (an ideal TV NTSC decoder).
151    let mut y = 0.0f64;
152    let mut i_acc = 0.0f64;
153    let mut q_acc = 0.0f64;
154    for ph in 0..12usize {
155        let spot = if wave_high(ph, color) { hi } else { lo };
156        // Normalize composite voltage to a 0..1 signal, then apply
157        // contrast (about mid-gray) and brightness (averaged over 12 phases).
158        let mut signal = (spot - BLACK) / (WHITE - BLACK);
159        signal = (signal - 0.5) * params.contrast + 0.5;
160        signal *= params.brightness / 12.0;
161
162        #[allow(clippy::cast_precision_loss)] // ph < 12 → exact in f64.
163        let angle = PI * (params.hue + ph as f64) / 6.0;
164        y += signal;
165        i_acc += signal * cos(angle);
166        q_acc += signal * sin(angle);
167    }
168    i_acc *= params.saturation;
169    q_acc *= params.saturation;
170
171    // FCC-sanctioned YIQ→RGB matrix, with gamma correction and 0..255 clamp.
172    let r = fcc_channel(y + 0.946_882 * i_acc + 0.623_557 * q_acc, params.gamma);
173    let g = fcc_channel(y - 0.274_788 * i_acc - 0.635_691 * q_acc, params.gamma);
174    let b = fcc_channel(y - 1.108_545 * i_acc + 1.709_007 * q_acc, params.gamma);
175    [r, g, b]
176}
177
178/// Gamma-correct one YIQ→RGB channel value and quantize to `u8` (0..255).
179///
180/// The `scaled as u8` in the mid branch is guarded: `scaled` is provably in the
181/// open interval `(0, 255)` there, so the truncation + sign-loss the cast would
182/// otherwise risk cannot occur (hence the justified `allow`).
183#[inline]
184#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
185fn fcc_channel(f: f64, gamma: f64) -> u8 {
186    // `gamma` comes from user-editable config; a non-finite or non-positive
187    // value would make `2.2 / gamma` blow up, so fall back to the neutral
188    // default. The `f`/`scaled` finiteness guards likewise contain a NaN that
189    // leaked in from any (also config-supplied) param, so a malformed config
190    // degrades to a defined pixel instead of an undefined float→int cast.
191    let safe_gamma = if gamma.is_finite() && gamma > 0.0 {
192        gamma
193    } else {
194        DEFAULT_GAMMA
195    };
196    let corrected = if f.is_finite() && f > 0.0 {
197        pow(f, 2.2 / safe_gamma)
198    } else {
199        0.0
200    };
201    let scaled = round(255.0 * corrected);
202    // Clamp; `round` keeps determinism (libm), the guards catch overflow + NaN.
203    if scaled.is_nan() || scaled <= 0.0 {
204        0
205    } else if scaled >= 255.0 {
206        255
207    } else {
208        scaled as u8
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    /// Luma of an RGB triple (integer approximation, for ordering assertions).
217    fn luma(rgb: [u8; 3]) -> u32 {
218        u32::from(rgb[0]) + u32::from(rgb[1]) + u32::from(rgb[2])
219    }
220
221    #[test]
222    fn generation_is_deterministic() {
223        // Same params ⇒ byte-identical output, every call. This is the hard
224        // contract that lets the committed golden snapshot hold across targets.
225        let p = NtscPaletteParams::default();
226        let a = generate_base_palette(&p);
227        let b = generate_base_palette(&p);
228        assert_eq!(a, b);
229    }
230
231    #[test]
232    fn grays_are_neutral_regardless_of_hue() {
233        // Color nibble 0 ($x0) is a chroma-free luma column: a constant signal
234        // integrates to zero chroma, so R==G==B for any hue/saturation.
235        for hue in [-2.0, 0.0, 1.0, 3.5] {
236            let p = NtscPaletteParams {
237                hue,
238                saturation: 1.5,
239                ..NtscPaletteParams::default()
240            };
241            let pal = generate_base_palette(&p);
242            for &pixel in &[0x00usize, 0x10, 0x20, 0x30] {
243                let [r, g, b] = pal[pixel];
244                assert!(
245                    r == g && g == b,
246                    "gray ${pixel:02X} not neutral: {r},{g},{b} (hue={hue})"
247                );
248            }
249        }
250    }
251
252    #[test]
253    fn white_black_anchors() {
254        let pal = generate_base_palette(&NtscPaletteParams::default());
255        // $0F is a forbidden black → pure black.
256        assert_eq!(pal[0x0F], [0, 0, 0], "$0F must be black");
257        // $20 is the brightest gray column entry → white (all channels max).
258        assert_eq!(pal[0x20], [255, 255, 255], "$20 must be white");
259        // $30 saturates at the same top level as $20 (color-0 column tops out).
260        assert_eq!(
261            pal[0x30], pal[0x20],
262            "$30 == $20 (color-0 column saturates)"
263        );
264    }
265
266    #[test]
267    fn gray_column_is_a_monotonic_luma_ramp() {
268        // $00 < $10 < $20 in luma (the color-0 column climbs, then saturates).
269        let pal = generate_base_palette(&NtscPaletteParams::default());
270        assert!(luma(pal[0x00]) < luma(pal[0x10]), "$00 !< $10");
271        assert!(luma(pal[0x10]) < luma(pal[0x20]), "$10 !< $20");
272    }
273
274    #[test]
275    fn saturation_zero_is_grayscale() {
276        // With no chroma gain every entry collapses to neutral gray.
277        let p = NtscPaletteParams {
278            saturation: 0.0,
279            ..NtscPaletteParams::default()
280        };
281        let pal = generate_base_palette(&p);
282        for (idx, &[r, g, b]) in pal.iter().enumerate() {
283            assert!(r == g && g == b, "idx ${idx:02X} not gray at saturation 0");
284        }
285    }
286
287    /// The default-parameter generated palette, captured once. This locks the
288    /// exact cross-target output: because every transcendental goes through
289    /// `libm`, this array must reproduce byte-for-byte on x86 / aarch64 / wasm /
290    /// `thumbv7em`. Regenerate **only** on a deliberate, reviewed model/param
291    /// change (a visual re-bless), never incidentally.
292    #[rustfmt::skip]
293    const GOLDEN_DEFAULT: [[u8; 3]; 64] = [
294        [83,83,83],[2,27,81],[16,15,102],[36,7,99],[54,3,75],[65,4,38],[63,10,5],[51,20,0],
295        [31,32,0],[12,43,0],[0,48,0],[0,46,10],[0,38,46],[0,0,0],[0,0,0],[0,0,0],
296        [160,160,160],[31,74,158],[57,55,189],[89,41,185],[117,34,149],[133,36,92],[131,46,36],[111,63,1],
297        [81,83,0],[50,99,0],[26,107,5],[15,105,47],[16,93,104],[0,0,0],[0,0,0],[0,0,0],
298        [255,255,255],[106,158,252],[137,136,255],[175,118,255],[207,110,242],[225,112,179],[222,125,113],[201,145,62],
299        [166,168,38],[129,187,40],[100,196,71],[85,193,125],[87,179,192],[60,60,60],[0,0,0],[0,0,0],
300        [255,255,255],[191,214,254],[205,204,255],[221,196,255],[235,192,250],[242,194,223],[241,199,194],[232,208,170],
301        [218,218,158],[201,226,159],[188,230,174],[181,229,200],[182,223,229],[169,169,169],[0,0,0],[0,0,0],
302    ];
303
304    #[test]
305    fn matches_committed_golden() {
306        // Cross-target byte-lock (see GOLDEN_DEFAULT). A drift here means either
307        // a model change (intended → regenerate the const in the same PR, with a
308        // visual re-bless) or a platform float divergence (a real bug).
309        let pal = generate_base_palette(&NtscPaletteParams::default());
310        assert_eq!(pal, GOLDEN_DEFAULT);
311    }
312
313    #[test]
314    fn malformed_params_never_panic() {
315        // A hand-edited config could carry non-finite / non-positive values.
316        // The synthesizer must degrade to a defined palette, never panic or
317        // produce an out-of-range channel (the float→int cast is guarded).
318        for bad in [
319            NtscPaletteParams {
320                gamma: 0.0,
321                ..NtscPaletteParams::default()
322            },
323            NtscPaletteParams {
324                gamma: -1.0,
325                ..NtscPaletteParams::default()
326            },
327            NtscPaletteParams {
328                gamma: f64::NAN,
329                ..NtscPaletteParams::default()
330            },
331            NtscPaletteParams {
332                saturation: f64::NAN,
333                brightness: f64::INFINITY,
334                contrast: f64::NAN,
335                hue: f64::NAN,
336                gamma: f64::NAN,
337            },
338        ] {
339            // Just constructing all 64 entries without a panic is the assertion
340            // (every channel is a valid u8 by type); a spot check keeps it honest.
341            let pal = generate_base_palette(&bad);
342            assert_eq!(pal.len(), 64);
343        }
344    }
345
346    #[test]
347    fn invalid_gamma_falls_back_to_default() {
348        // A non-finite / non-positive gamma resolves to DEFAULT_GAMMA, so the
349        // output equals the default-gamma palette (the other params here are the
350        // defaults). This proves the guard, not just the absence of a panic.
351        let good = generate_base_palette(&NtscPaletteParams::default());
352        for bad_gamma in [0.0, -3.0, f64::NAN, f64::INFINITY] {
353            let bad = generate_base_palette(&NtscPaletteParams {
354                gamma: bad_gamma,
355                ..NtscPaletteParams::default()
356            });
357            assert_eq!(bad, good, "gamma={bad_gamma} should fall back to default");
358        }
359    }
360
361    #[test]
362    fn colored_entries_are_actually_colored() {
363        // A mid-luma chroma entry (e.g. $16, a red) must have a real channel
364        // spread at default saturation — proving chroma demodulation works.
365        let pal = generate_base_palette(&NtscPaletteParams::default());
366        let [r, g, b] = pal[0x16];
367        let max = r.max(g).max(b);
368        let min = r.min(g).min(b);
369        assert!(max - min > 20, "$16 not colored enough: {r},{g},{b}");
370    }
371}