Skip to main content

rustynes_ppu/
raw_signal.rs

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