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}