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}