Skip to main content

rustynes_ppu/
raw_signal.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the raw composite-signal model follows Bisqwit's documented `nes_ntsc` method (NESdev "NTSC video") and Mesen2's "raw palette" generator (GPL-3.0-or-later), as docs/ppu-2c02.md has described it since v2.1.9. Recorded v2.9.8 at the maintainer's direction. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! Raw NTSC composite-signal model (v2.1.9 "Presentation & Signal", P4).
6//!
7//! Where `palette_gen` *pre-decodes* each of the 64 base colors to a
8//! single RGB triple (an ideal TV integrated over one pixel), this module keeps
9//! the signal **un-decoded**: for every `(index, emphasis)` pair it emits the
10//! 2C02's raw composite waveform as the twelve per-subcarrier-phase voltage
11//! samples the chip actually generates within one pixel. A shader (or any host
12//! NTSC decoder) can then run a *real* NTSC demodulation across neighbouring
13//! pixels' waveforms and reproduce the signal-domain artifacts a per-color RGB
14//! palette structurally cannot: composite color bleed, dot crawl, the
15//! "waterfall"/dither transparency tricks (e.g. Kirby's Adventure waterfalls,
16//! the Zelda II title, and the classic 240p test suite color-bleed screens) that
17//! rely on adjacent-pixel chroma mixing rather than on any one pixel's color.
18//!
19//! ## The Mesen / Bisqwit "raw palette" model
20//!
21//! This follows the canonical Bisqwit `nes_ntsc` signal generator (nesdev wiki
22//! "NTSC video"), the same model Mesen2 exposes as its *raw* NTSC filter:
23//!
24//! * The 2C02 emits, per pixel, a two-level chroma square wave over 12 equal
25//!   subcarrier phases. Which six of the twelve phases are "high" is set by the
26//!   color's hue nibble (`InColorPhase`); the two voltage levels (low/high) are
27//!   set by the luma nibble via [`LEVELS`].
28//! * Grays (`$x0`, `$xD`) hold a constant level **with no chroma at
29//!   `emphasis == 0`**, so a decoder integrates the un-emphasized gray to zero
30//!   saturation regardless of hue. Note this "flat/no chroma" property is
31//!   scoped to `emphasis == 0`: the emphasis attenuation below is *phase-
32//!   selective*, so under `emphasis != 0` even a gray becomes non-flat across
33//!   the twelve phases (it picks up a small chroma component alongside the
34//!   darkening).
35//! * The three emphasis bits each attenuate the signal (by [`ATTENUATION`])
36//!   during the subcarrier phases that overlap "their" primary's hue region —
37//!   which is why enabling all three darkens (near-)uniformly while enabling one
38//!   tints (and, per the note above, breaks a gray's flatness).
39//!
40//! ## Determinism boundary (why this is `no_std` and float-locked)
41//!
42//! The waveform is built from **level lookups, one multiply (emphasis), and one
43//! affine normalize** — there is *no* transcendental (no `sin`/`cos`/`pow`), so
44//! the `f32` output is bit-identical across x86 / aarch64 / wasm / `thumbv7em`
45//! under IEEE-754 without needing `libm`. The committed `GOLDEN_SIGNAL`
46//! snapshot locks that cross-target contract.
47//!
48//! ## Where this sits in the pipeline (additive, default-OFF)
49//!
50//! This is a **new, parallel** output. The default presentation path is
51//! untouched: the shipped build still pre-decodes through [`crate::NES_PALETTE`]
52//! / `palette::build_rgba_lut_from_base`, so the default framebuffer
53//! golden vectors and `AccuracyCoin` are byte-identical. The raw signal is only
54//! consumed when the frontend explicitly selects the signal-decode presentation
55//! shader (a deliberate visual choice, gated + re-blessed like the generated
56//! palette in F1.4 / v2.0.3). Nothing here feeds the deterministic core.
57
58// The palette/level constants below are Bisqwit's canonical voltages; the affine
59// normalize keeps two-rounding form for the same cross-target determinism reason
60// `palette_gen` documents (a fused `mul_add` could round differently and break
61// the committed golden). Mirror its allow.
62#![allow(clippy::suboptimal_flops)]
63
64/// The eight composite signal voltage levels the 2C02 emits, relative to sync.
65///
66/// Identical to `palette_gen`'s `LEVELS`, restated here so the raw-
67/// signal model is self-contained. Indices `0..4` are the "signal low" half of
68/// the chroma square wave for luma levels `0..3`; `4..8` are the "signal high"
69/// half. (Bisqwit / nesdev "NTSC video".)
70pub const LEVELS: [f32; 8] = [
71    0.350, 0.518, 0.962, 1.550, // signal low  (luma level 0..3)
72    1.094, 1.506, 1.962, 1.962, // signal high (luma level 0..3)
73];
74
75/// Black reference voltage (the composite level that normalizes to 0.0).
76pub const BLACK: f32 = 0.518;
77/// White reference voltage (the composite level that normalizes to 1.0).
78pub const WHITE: f32 = 1.962;
79/// Per-emphasis-bit attenuation factor (≈ −2.5 dB) applied during the phases
80/// that overlap the emphasized primary's hue region. (Bisqwit / nesdev.)
81pub const ATTENUATION: f32 = 0.746;
82
83/// The number of distinct subcarrier phases the 2C02 walks within one pixel.
84/// A full color-decode integrates over exactly these twelve samples.
85pub const PHASES: usize = 12;
86
87/// The number of `(index, emphasis)` entries in a full raw-signal LUT:
88/// 64 base colors × 8 emphasis states.
89pub const RAW_ENTRIES: usize = 64 * 8;
90
91/// Return `true` when the chroma square wave for hue `color` (0..15) is in its
92/// "high" state at subcarrier phase `phase` (0..12).
93///
94/// This is Bisqwit's `InColorPhase`: `((color + phase) % 12) < 6`. It is the
95/// phase generator that positions each of the twelve hues on the color wheel.
96/// (Note the phase *convention* differs from `palette_gen`'s `+ 8`
97/// offset — the two are independent decoders; what matters is that this module
98/// is self-consistent with the Bisqwit decode a signal shader performs.)
99#[inline]
100#[must_use]
101pub const fn in_color_phase(color: usize, phase: usize) -> bool {
102    (color + phase) % 12 < 6
103}
104
105/// Compute the raw composite voltage (relative to sync) for one subcarrier
106/// `phase` (0..12) of NES palette `index` (0..=63) under `emphasis` (0..=7,
107/// bit0 = red, bit1 = green, bit2 = blue).
108///
109/// This is the un-normalized chip output: the chosen [`LEVELS`] entry, times the
110/// emphasis attenuation when any set emphasis bit's hue region overlaps `phase`.
111#[inline]
112#[must_use]
113pub fn composite_voltage(index: usize, emphasis: usize, phase: usize) -> f32 {
114    let color = index & 0x0F; // hue nibble (0..15)
115    // Colors $0E/$0F are forbidden blacks; clamp their luma level so the index
116    // math is well-defined (they resolve to black regardless).
117    let level = if color < 0x0E { (index >> 4) & 3 } else { 1 }; // 0..3
118
119    // High half only when the wave is high AND this hue actually has a high
120    // level: color $0 (gray) forces low->high level (no chroma); colors
121    // $0D..$0F have no high level (their nominal "high" stays low -> dark).
122    // `level + 4*flag` is provably 0..7 -> in-bounds for `LEVELS`.
123    let high = in_color_phase(color, phase) || color == 0x00;
124    let lo = LEVELS[level + 4 * usize::from(color == 0x00)];
125    let hi = LEVELS[level + 4 * usize::from(color < 0x0D)];
126    let mut wave = if high { hi } else { lo };
127
128    // Emphasis: attenuate during the phases overlapping each set bit's primary.
129    // The three primaries sit at hue anchors 0 (red), 4 (green), 8 (blue) on the
130    // `InColorPhase` wheel. Any overlapping set bit applies one attenuation
131    // (matching Bisqwit — the factors do not stack per bit within a phase).
132    let emphasized = ((emphasis & 1) != 0 && in_color_phase(0, phase))
133        || ((emphasis & 2) != 0 && in_color_phase(4, phase))
134        || ((emphasis & 4) != 0 && in_color_phase(8, phase));
135    if emphasized {
136        wave *= ATTENUATION;
137    }
138    wave
139}
140
141/// Normalize a raw composite voltage to the shader-friendly `[0.0, 1.0]` range.
142///
143/// Maps the black reference to `0.0` and white to `1.0`; values outside are
144/// possible under emphasis and are left un-clamped so a decoder sees the true
145/// signal excursion.
146#[inline]
147#[must_use]
148pub fn normalize(voltage: f32) -> f32 {
149    (voltage - BLACK) / (WHITE - BLACK)
150}
151
152/// Build the twelve normalized composite samples for one `(index, emphasis)`
153/// pair — the per-pixel waveform a signal-decode shader convolves across
154/// neighbouring pixels.
155#[must_use]
156pub fn signal_samples(index: usize, emphasis: usize) -> [f32; PHASES] {
157    let mut out = [0.0f32; PHASES];
158    for (phase, slot) in out.iter_mut().enumerate() {
159        *slot = normalize(composite_voltage(index, emphasis, phase));
160    }
161    out
162}
163
164/// Generate the full raw-signal LUT: [`RAW_ENTRIES`] rows (index-major,
165/// `index * 8 + emphasis`), each the twelve normalized subcarrier samples.
166///
167/// This is the exact table a host uploads (e.g. as an `R32Float` /
168/// `Rgba8Unorm`-packed texture) for the signal-decode shader. Deterministic and
169/// `no_std`; see `GOLDEN_SIGNAL` for the cross-target byte-lock. The
170/// 24 KiB table is built directly on the heap (via the crate's `alloc`, never a
171/// stack temporary) as a [`RAW_ENTRIES`]-long boxed slice — generated once at
172/// shader-setup time, never on a hot path.
173#[must_use]
174pub fn generate_raw_signal_lut() -> alloc::boxed::Box<[[f32; PHASES]]> {
175    let mut lut = alloc::vec::Vec::with_capacity(RAW_ENTRIES);
176    for index in 0..64usize {
177        for emphasis in 0..8usize {
178            lut.push(signal_samples(index, emphasis));
179        }
180    }
181    lut.into_boxed_slice()
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// A constant-signal color (gray column) must produce a flat waveform — the
189    /// property that guarantees any NTSC decoder integrates it to zero chroma.
190    #[test]
191    fn gray_columns_are_flat() {
192        for &index in &[0x00usize, 0x10, 0x20, 0x30] {
193            let s = signal_samples(index, 0);
194            for &v in &s {
195                assert!((v - s[0]).abs() < 1e-6, "gray ${index:02X} not flat: {s:?}");
196            }
197        }
198    }
199
200    /// A mid-luma chroma color must actually oscillate (six high, six low
201    /// phases) — proving the chroma square wave is present for a decoder to
202    /// demodulate.
203    #[test]
204    fn chroma_colors_oscillate() {
205        // $16 (a red): hue nibble 6, so exactly six phases high.
206        let s = signal_samples(0x16, 0);
207        let hi = s.iter().filter(|&&v| v > 0.5).count();
208        assert_eq!(hi, 6, "$16 should have 6 high phases, got {hi}: {s:?}");
209    }
210
211    /// Emphasis must only ever *reduce* the signal (never brighten it), and full
212    /// emphasis ($e7) on a bright color must reduce at least some phases — the
213    /// darkening contract.
214    #[test]
215    fn emphasis_only_attenuates() {
216        for index in 0..64usize {
217            let base = signal_samples(index, 0);
218            for emphasis in 1..8usize {
219                let emph = signal_samples(index, emphasis);
220                for phase in 0..PHASES {
221                    assert!(
222                        emph[phase] <= base[phase] + 1e-6,
223                        "emphasis {emphasis} brightened ${index:02X} phase {phase}"
224                    );
225                }
226            }
227        }
228        // A bright non-gray color under full emphasis must actually drop.
229        let base = signal_samples(0x21, 0);
230        let full = signal_samples(0x21, 7);
231        assert!(
232            full.iter().zip(base).any(|(f, b)| *f < b - 1e-4),
233            "full emphasis did not attenuate $21"
234        );
235    }
236
237    /// The normalize anchors: black reference -> 0, white reference -> 1.
238    #[test]
239    fn normalize_anchors() {
240        assert!((normalize(BLACK) - 0.0).abs() < 1e-6);
241        assert!((normalize(WHITE) - 1.0).abs() < 1e-6);
242    }
243
244    /// Determinism: the LUT is a pure function; two builds are byte-identical.
245    #[test]
246    fn lut_is_deterministic() {
247        assert_eq!(generate_raw_signal_lut(), generate_raw_signal_lut());
248    }
249
250    /// Cross-target byte-lock for the first eight LUT rows (index $00 across all
251    /// eight emphasis states). Because the model uses no transcendentals, this
252    /// must reproduce bit-for-bit on every target. A drift means either an
253    /// intended model change (regenerate + visual re-bless) or a real float bug.
254    /// Full 512-row snapshotting is done via `insta` in the frontend; this small
255    /// in-crate lock keeps the `no_std` crate self-guarding.
256    #[rustfmt::skip]
257    const GOLDEN_SIGNAL: [[f32; PHASES]; 8] = {
258        // $00 is gray level-1: flat at normalize(LEVELS[1+4]=1.506) for all
259        // phases (color 0 forces the high level), attenuated per emphasis on the
260        // phases overlapping each primary. Computed by the same code path.
261        [
262            signal_row(0x00, 0), signal_row(0x00, 1), signal_row(0x00, 2), signal_row(0x00, 3),
263            signal_row(0x00, 4), signal_row(0x00, 5), signal_row(0x00, 6), signal_row(0x00, 7),
264        ]
265    };
266
267    /// `const`-evaluable sibling of [`signal_samples`] for the golden table.
268    const fn signal_row(index: usize, emphasis: usize) -> [f32; PHASES] {
269        let mut out = [0.0f32; PHASES];
270        let mut phase = 0;
271        while phase < PHASES {
272            // Inline of `normalize(composite_voltage(..))` in const form.
273            let color = index & 0x0F;
274            let level = if color < 0x0E { (index >> 4) & 3 } else { 1 };
275            let high = in_color_phase(color, phase) || color == 0x00;
276            let lo = LEVELS[level + 4 * (color == 0x00) as usize];
277            let hi = LEVELS[level + 4 * (color < 0x0D) as usize];
278            let mut wave = if high { hi } else { lo };
279            let emphasized = (emphasis & 1 != 0 && in_color_phase(0, phase))
280                || (emphasis & 2 != 0 && in_color_phase(4, phase))
281                || (emphasis & 4 != 0 && in_color_phase(8, phase));
282            if emphasized {
283                wave *= ATTENUATION;
284            }
285            out[phase] = (wave - BLACK) / (WHITE - BLACK);
286            phase += 1;
287        }
288        out
289    }
290
291    // Exact f32 equality is deliberate here: the whole point of GOLDEN_SIGNAL is
292    // a byte-for-byte cross-target lock, so an approximate compare would defeat
293    // it (a platform float divergence must fail, not be tolerated).
294    #[test]
295    #[allow(clippy::float_cmp)]
296    fn matches_committed_golden() {
297        let lut = generate_raw_signal_lut();
298        for emphasis in 0..8usize {
299            assert_eq!(
300                lut[emphasis], GOLDEN_SIGNAL[emphasis],
301                "row $00 emphasis {emphasis} drifted from golden"
302            );
303        }
304    }
305}