Skip to main content

display_types/
timing.rs

1use crate::{CvtAlgorithm, RefreshRate, VideoMode};
2
3/// Returns the pixel clock in kHz for a [`VideoMode`].
4///
5/// When `mode.pixel_clock_khz` is `Some` (set from a Detailed Timing Descriptor), returns
6/// that exact value directly. When it is `None` (modes decoded from standard timings,
7/// established timings, or SVD entries that lack a DTD), falls back to a CVT Reduced
8/// Blanking estimate:
9///
10/// - **Horizontal blanking:** 160 pixels (CVT-RB fixed blank, VESA CVT 1.2 §2.2).
11/// - **Vertical blanking:** 8 lines (minimum RB frame-height adjustment).
12///
13/// ```text
14/// pixel_clock_khz ≈ (width + 160) × (height + 8) × refresh_rate / 1000
15/// ```
16///
17/// Returns `0` when neither `mode.pixel_clock_khz` nor `mode.refresh_rate` is set.
18///
19/// The estimate saturates at `u32::MAX` rather than wrapping. Mode geometry comes from
20/// sink-supplied EDID data and can describe clocks far beyond any real link (e.g. a
21/// 65536×65536 @ 1024 Hz CTA Type X timing); a saturated value fails every bandwidth
22/// ceiling check, whereas a wrapped one could pass.
23///
24/// # Accuracy of the fallback estimate
25///
26/// CVT-RB is the dominant timing standard for modern display modes. For typical consumer
27/// resolutions the estimate is within ~2% of the actual clock. HDMI Forum-specified CTA
28/// modes (e.g. 4K@60, VIC 97) use larger blanking than CVT-RB predicts and may be
29/// under-estimated by ~10–15%, which can produce false accepts in bandwidth ceiling checks.
30/// Interlaced modes diverge further.
31///
32/// The fallback is only used when no exact clock is available. Prefer populating
33/// `pixel_clock_khz` from the EDID Detailed Timing Descriptor wherever possible.
34pub fn pixel_clock_khz(mode: &VideoMode) -> u32 {
35    if let Some(clk) = mode.pixel_clock_khz {
36        return clk;
37    }
38    let Some(rr) = mode.refresh_rate else {
39        return 0;
40    };
41    // u128: (u16::MAX + 160) × (u16::MAX + 8) × u32::MAX exceeds u64::MAX.
42    let h_total = u128::from(mode.width) + 160;
43    let v_total = u128::from(mode.height) + 8;
44    let numer = u128::from(rr.numer());
45    let denom = u128::from(rr.denom());
46    u32::try_from(h_total * v_total * numer / (denom * 1000)).unwrap_or(u32::MAX)
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::VideoMode;
53
54    #[test]
55    fn exact_clock_returned_unchanged() {
56        let mode = VideoMode::new(1920, 1080, 60u32, false).with_detailed_timing(
57            148_500,
58            88,
59            44,
60            4,
61            5,
62            0,
63            0,
64            Default::default(),
65            None,
66        );
67        assert_eq!(pixel_clock_khz(&mode), 148_500);
68    }
69
70    #[test]
71    fn with_pixel_clock_bypasses_estimate() {
72        let mode = VideoMode::new(1920, 1200, 60u32, false).with_pixel_clock(154_000);
73        assert_eq!(pixel_clock_khz(&mode), 154_000);
74    }
75
76    #[test]
77    fn non_dtd_mode_uses_cvt_rb_formula() {
78        // 1920×1080@60: (1920+160) × (1080+8) × 60 / 1000 = 135_782
79        let mode = VideoMode::new(1920, 1080, 60u32, false);
80        assert_eq!(pixel_clock_khz(&mode), 135_782);
81    }
82
83    #[test]
84    fn zero_refresh_rate_returns_zero() {
85        let mode = VideoMode::new(1920, 1080, 0u32, false);
86        assert_eq!(pixel_clock_khz(&mode), 0);
87    }
88
89    #[test]
90    fn unset_refresh_rate_returns_zero() {
91        let mode = VideoMode {
92            width: 1920,
93            height: 1080,
94            refresh_rate: None,
95            ..Default::default()
96        };
97        assert_eq!(pixel_clock_khz(&mode), 0);
98    }
99
100    #[test]
101    fn oversized_estimate_saturates() {
102        // (65534 + 160) × (63842 + 8) × 1024 / 1000 ≈ 4.295e9 kHz, just above u32::MAX.
103        // A truncating cast wrapped this to 264_089 kHz, which passed TMDS ceiling checks.
104        let mode = VideoMode::new(65534, 63842, 1024u32, false);
105        assert_eq!(pixel_clock_khz(&mode), u32::MAX);
106    }
107
108    #[test]
109    fn maximum_geometry_estimate_saturates() {
110        let mode = VideoMode::new(u16::MAX, u16::MAX, u32::MAX, false);
111        assert_eq!(pixel_clock_khz(&mode), u32::MAX);
112    }
113}
114
115/// Pixel clock and blanking parameters computed from a CVT formula.
116///
117/// All fields are derived from `(width, height, refresh_rate, cvt_algorithm)`. Designed
118/// to feed [`VideoMode::with_detailed_timing`][crate::VideoMode::with_detailed_timing]
119/// directly: the five fields it carries map 1:1 onto that builder's
120/// `pixel_clock_khz` / `h_front_porch` / `h_sync_width` / `v_front_porch` / `v_sync_width`
121/// arguments. `h_total` and `v_total` are exposed for sanity checks (e.g. bandwidth
122/// estimation against the CVT-rounded clock).
123#[non_exhaustive]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct ComputedTiming {
127    /// Pixel clock in kHz, rounded down to the algorithm's clock step (CVT-RB v1: 250 kHz).
128    pub pixel_clock_khz: u32,
129    /// `width + horizontal blanking`. Useful for bandwidth checks.
130    pub h_total: u16,
131    /// `height + vertical blanking`. Useful for bandwidth checks.
132    pub v_total: u16,
133    /// Horizontal front porch in pixels.
134    pub h_front_porch: u16,
135    /// Horizontal sync pulse width in pixels.
136    pub h_sync_width: u16,
137    /// Vertical front porch in lines.
138    pub v_front_porch: u16,
139    /// Vertical sync pulse width in lines.
140    pub v_sync_width: u16,
141}
142
143// CVT-RB v1 constants (VESA CVT 1.1 §3.4 "Reduced Blanking" timing).
144const RB_V1_CLOCK_STEP_KHZ: u32 = 250; // 0.25 MHz pixel-clock granularity
145const RB_V1_MIN_V_BLANK_US: u32 = 460; // microseconds, fixed
146const RB_V1_H_BLANK: u16 = 160; // pixels, fixed total H blanking
147const RB_V1_H_SYNC: u16 = 32; // pixels, fixed
148const RB_V1_H_BPORCH: u16 = 80; // pixels, fixed
149const RB_V1_H_FPORCH: u16 = RB_V1_H_BLANK - RB_V1_H_SYNC - RB_V1_H_BPORCH; // 48
150const RB_V1_V_FPORCH: u16 = 3; // lines, fixed
151const RB_V1_V_SYNC: u16 = 4; // lines, fixed
152const RB_V1_MIN_V_BPORCH: u16 = 6; // lines, lower bound
153
154// CVT-RB v2 constants (VESA CVT 1.2 §4 "Reduced Blanking version 2" timing).
155const RB_V2_CLOCK_STEP_KHZ: u32 = 1; // 0.001 MHz (1 kHz) pixel-clock granularity
156const RB_V2_MIN_V_BLANK_US: u32 = 460; // microseconds, fixed
157const RB_V2_H_BLANK: u16 = 80; // pixels, fixed total H blanking (half of RB v1)
158const RB_V2_H_SYNC: u16 = 32; // pixels, fixed
159const RB_V2_H_BPORCH: u16 = 40; // pixels, fixed
160const RB_V2_H_FPORCH: u16 = RB_V2_H_BLANK - RB_V2_H_SYNC - RB_V2_H_BPORCH; // 8
161const RB_V2_MIN_V_FPORCH: u16 = 1; // lines, lower bound (slack lives here, not in back porch)
162const RB_V2_V_SYNC: u16 = 8; // lines, fixed (v1 used 4)
163const RB_V2_V_BPORCH: u16 = 6; // lines, fixed (v1 had a 6-line minimum but variable back porch)
164
165/// Computes pixel clock and blanking parameters for a DisplayID 2.x Type IX
166/// (Formula-Based Timing) descriptor using the named CVT variant.
167///
168/// Returns `None` for:
169/// - degenerate input (`width == 0`, `height == 0`, non-positive or non-finite refresh rate)
170/// - `CvtAlgorithm::Cvt` (standard CVT, not reduced blanking — no evaluator implemented)
171/// - the `Reserved(_)` algorithm encoding
172///
173/// The returned [`ComputedTiming`] feeds [`VideoMode::with_detailed_timing`] directly.
174///
175/// # Algorithm coverage
176///
177/// | `CvtAlgorithm` variant | Status |
178/// |------------------------|--------|
179/// | `Cvt`                  | not implemented (standard CVT, no reduced blanking) — returns `None` |
180/// | `CvtRb`                | implemented (CVT-RB v1, VESA CVT 1.1 §3.4) |
181/// | `CvtR2`                | implemented (CVT-RB v2, VESA CVT 1.2 §4) |
182/// | `Reserved(_)`          | always `None` |
183pub fn compute_type_ix_timing(
184    width: u16,
185    height: u16,
186    refresh_rate: RefreshRate,
187    algorithm: CvtAlgorithm,
188) -> Option<ComputedTiming> {
189    match algorithm {
190        CvtAlgorithm::CvtRb => cvt_rb_v1(width, height, refresh_rate),
191        CvtAlgorithm::CvtR2 => cvt_rb_v2(width, height, refresh_rate),
192        _ => None,
193    }
194}
195
196// f64::floor() and f64::ceil() are not available in no_std (they require libm).
197// These helpers use integer truncation, which is correct for all finite positive
198// inputs — the only values the CVT evaluators ever produce.
199fn floor_f64(x: f64) -> f64 {
200    let n = x as i64;
201    if (n as f64) > x {
202        (n - 1) as f64
203    } else {
204        n as f64
205    }
206}
207fn ceil_f64(x: f64) -> f64 {
208    let n = x as i64;
209    if (n as f64) < x {
210        (n + 1) as f64
211    } else {
212        n as f64
213    }
214}
215
216/// CVT-RB v1 evaluator. See [`compute_type_ix_timing`] for the public entry point.
217fn cvt_rb_v1(width: u16, height: u16, refresh_rate: RefreshRate) -> Option<ComputedTiming> {
218    if width == 0 || height == 0 {
219        return None;
220    }
221    let v_field_rate_hz = refresh_rate.as_f64();
222    if !v_field_rate_hz.is_finite() || v_field_rate_hz <= 0.0 {
223        return None;
224    }
225
226    let v_active = u32::from(height);
227    let frame_period_us = 1_000_000.0 / v_field_rate_hz;
228
229    // Horizontal line period estimate, used to derive how many lines fit in the fixed
230    // 460 µs minimum vertical blanking.
231    let h_period_est_us = (frame_period_us - f64::from(RB_V1_MIN_V_BLANK_US))
232        / f64::from(v_active + u32::from(RB_V1_V_FPORCH));
233    if h_period_est_us <= 0.0 {
234        return None; // refresh too high to fit RB1's minimum blanking budget
235    }
236
237    let vbi_lines = ceil_f64(f64::from(RB_V1_MIN_V_BLANK_US) / h_period_est_us) as u32;
238    let rb_min_vbi =
239        u32::from(RB_V1_V_FPORCH) + u32::from(RB_V1_V_SYNC) + u32::from(RB_V1_MIN_V_BPORCH);
240    let actual_vbi_lines = vbi_lines.max(rb_min_vbi);
241
242    let v_total = v_active + actual_vbi_lines;
243    let h_total = u32::from(width) + u32::from(RB_V1_H_BLANK);
244
245    // Pixel clock: V_FIELD_RATE × V_TOTAL × H_TOTAL, floored to the 250 kHz step.
246    let pixel_clock_hz = v_field_rate_hz * f64::from(v_total) * f64::from(h_total);
247    let pixel_clock_khz_steps =
248        floor_f64(pixel_clock_hz / 1000.0 / f64::from(RB_V1_CLOCK_STEP_KHZ)) as u32;
249    let pixel_clock_khz = pixel_clock_khz_steps * RB_V1_CLOCK_STEP_KHZ;
250
251    // Sanity: H_TOTAL and V_TOTAL must fit in u16 for VideoMode.
252    let h_total = u16::try_from(h_total).ok()?;
253    let v_total = u16::try_from(v_total).ok()?;
254
255    Some(ComputedTiming {
256        pixel_clock_khz,
257        h_total,
258        v_total,
259        h_front_porch: RB_V1_H_FPORCH,
260        h_sync_width: RB_V1_H_SYNC,
261        v_front_porch: RB_V1_V_FPORCH,
262        v_sync_width: RB_V1_V_SYNC,
263    })
264}
265
266/// CVT-RB v2 evaluator. See [`compute_type_ix_timing`] for the public entry point.
267///
268/// Differences from v1: half the horizontal blanking (80 vs 160 px), 1 kHz pixel-clock
269/// step (vs 0.25 MHz), V_SYNC widened to 8 lines (was 4), V_BPORCH fixed at 6 lines
270/// (was a 6-line minimum), and the variable slack lives in V_FPORCH (≥ 1 line) rather
271/// than V_BPORCH.
272fn cvt_rb_v2(width: u16, height: u16, refresh_rate: RefreshRate) -> Option<ComputedTiming> {
273    if width == 0 || height == 0 {
274        return None;
275    }
276    let v_field_rate_hz = refresh_rate.as_f64();
277    if !v_field_rate_hz.is_finite() || v_field_rate_hz <= 0.0 {
278        return None;
279    }
280
281    let v_active = u32::from(height);
282    let frame_period_us = 1_000_000.0 / v_field_rate_hz;
283
284    // RB v2 doesn't add V_FPORCH to the divisor (unlike v1's `(v_active + V_FPORCH)`).
285    let h_period_est_us = (frame_period_us - f64::from(RB_V2_MIN_V_BLANK_US)) / f64::from(v_active);
286    if h_period_est_us <= 0.0 {
287        return None; // refresh too high to fit RB v2's minimum blanking budget
288    }
289
290    let vbi_lines = ceil_f64(f64::from(RB_V2_MIN_V_BLANK_US) / h_period_est_us) as u32;
291    let rb_min_vbi =
292        u32::from(RB_V2_MIN_V_FPORCH) + u32::from(RB_V2_V_SYNC) + u32::from(RB_V2_V_BPORCH);
293    let actual_vbi_lines = vbi_lines.max(rb_min_vbi);
294
295    let v_total = v_active + actual_vbi_lines;
296    let h_total = u32::from(width) + u32::from(RB_V2_H_BLANK);
297
298    // Pixel clock: V_FIELD_RATE × V_TOTAL × H_TOTAL, floored to the 1 kHz step.
299    let pixel_clock_hz = v_field_rate_hz * f64::from(v_total) * f64::from(h_total);
300    let pixel_clock_khz_steps =
301        floor_f64(pixel_clock_hz / 1000.0 / f64::from(RB_V2_CLOCK_STEP_KHZ)) as u32;
302    let pixel_clock_khz = pixel_clock_khz_steps * RB_V2_CLOCK_STEP_KHZ;
303
304    // V_FPORCH carries the slack in v2: VBI = V_FPORCH + V_SYNC + V_BPORCH.
305    let v_front_porch_u32 = actual_vbi_lines - u32::from(RB_V2_V_SYNC) - u32::from(RB_V2_V_BPORCH);
306    let v_front_porch = u16::try_from(v_front_porch_u32).ok()?;
307
308    // Sanity: H_TOTAL and V_TOTAL must fit in u16 for VideoMode.
309    let h_total = u16::try_from(h_total).ok()?;
310    let v_total = u16::try_from(v_total).ok()?;
311
312    Some(ComputedTiming {
313        pixel_clock_khz,
314        h_total,
315        v_total,
316        h_front_porch: RB_V2_H_FPORCH,
317        h_sync_width: RB_V2_H_SYNC,
318        v_front_porch,
319        v_sync_width: RB_V2_V_SYNC,
320    })
321}
322
323#[cfg(test)]
324mod cvt_tests {
325    use super::*;
326
327    #[test]
328    fn cvt_rb_v1_1920x1080_at_60() {
329        // Canonical CVT-RB v1 mode. VESA-published value: 138.500 MHz pixel clock,
330        // h_total = 2080, v_total = 1111.
331        let t = compute_type_ix_timing(1920, 1080, RefreshRate::integral(60), CvtAlgorithm::CvtRb)
332            .expect("CVT-RB v1 must produce a timing");
333        assert_eq!(t.pixel_clock_khz, 138_500);
334        assert_eq!(t.h_total, 2080);
335        assert_eq!(t.v_total, 1111);
336        assert_eq!(t.h_front_porch, 48);
337        assert_eq!(t.h_sync_width, 32);
338        assert_eq!(t.v_front_porch, 3);
339        assert_eq!(t.v_sync_width, 4);
340    }
341
342    #[test]
343    fn cvt_rb_v1_2560x1440_at_60() {
344        // CVT-RB v1 reference: 241.500 MHz, h_total = 2720, v_total = 1481.
345        let t = compute_type_ix_timing(2560, 1440, RefreshRate::integral(60), CvtAlgorithm::CvtRb)
346            .expect("CVT-RB v1 must produce a timing");
347        assert_eq!(t.pixel_clock_khz, 241_500);
348        assert_eq!(t.h_total, 2720);
349        assert_eq!(t.v_total, 1481);
350    }
351
352    #[test]
353    fn cvt_rb_v1_3840x2160_at_30() {
354        // CVT-RB v1 reference: 262.750 MHz, h_total = 4000, v_total = 2191.
355        let t = compute_type_ix_timing(3840, 2160, RefreshRate::integral(30), CvtAlgorithm::CvtRb)
356            .expect("CVT-RB v1 must produce a timing");
357        assert_eq!(t.pixel_clock_khz, 262_750);
358        assert_eq!(t.h_total, 4000);
359        assert_eq!(t.v_total, 2191);
360    }
361
362    #[test]
363    fn cvt_rb_v1_zero_width_returns_none() {
364        assert!(
365            compute_type_ix_timing(0, 1080, RefreshRate::integral(60), CvtAlgorithm::CvtRb)
366                .is_none()
367        );
368    }
369
370    #[test]
371    fn cvt_rb_v1_zero_height_returns_none() {
372        assert!(
373            compute_type_ix_timing(1920, 0, RefreshRate::integral(60), CvtAlgorithm::CvtRb)
374                .is_none()
375        );
376    }
377
378    #[test]
379    fn cvt_rb_v1_unreachable_refresh_returns_none() {
380        // Refresh so high that frame period is below the 460 µs RB minimum.
381        // 1/3000 s = 333 µs < 460 µs → no time for active video.
382        assert!(
383            compute_type_ix_timing(1920, 1080, RefreshRate::integral(3000), CvtAlgorithm::CvtRb)
384                .is_none()
385        );
386    }
387
388    #[test]
389    fn cvt_rb_v2_1920x1080_at_60() {
390        // CVT-RB v2 1920×1080@60: half the H blanking of v1, slack in V_FPORCH.
391        // h_total = 2000, v_total = 1111 → 60 × 2000 × 1111 = 133_320_000 Hz → 133_320 kHz.
392        let t = compute_type_ix_timing(1920, 1080, RefreshRate::integral(60), CvtAlgorithm::CvtR2)
393            .expect("CVT-RB v2 must produce a timing");
394        assert_eq!(t.pixel_clock_khz, 133_320);
395        assert_eq!(t.h_total, 2000);
396        assert_eq!(t.v_total, 1111);
397        assert_eq!(t.h_front_porch, 8);
398        assert_eq!(t.h_sync_width, 32);
399        // VBI = 31, V_SYNC = 8, V_BPORCH = 6 → V_FPORCH = 31 - 8 - 6 = 17.
400        assert_eq!(t.v_front_porch, 17);
401        assert_eq!(t.v_sync_width, 8);
402    }
403
404    #[test]
405    fn cvt_rb_v2_2560x1440_at_120() {
406        // 144/240 Hz panels typically use RB v2. h_total = 2640, v_total = 1525.
407        // 120 × 2640 × 1525 = 483_120_000 Hz → 483_120 kHz.
408        let t = compute_type_ix_timing(2560, 1440, RefreshRate::integral(120), CvtAlgorithm::CvtR2)
409            .expect("CVT-RB v2 must produce a timing");
410        assert_eq!(t.pixel_clock_khz, 483_120);
411        assert_eq!(t.h_total, 2640);
412        assert_eq!(t.v_total, 1525);
413    }
414
415    #[test]
416    fn cvt_rb_v2_3840x2160_at_60() {
417        // 4K@60 RB v2: h_total = 3920, v_total = 2222.
418        // 60 × 3920 × 2222 = 522_614_400 Hz → 522_614 kHz (floor of 522614.4).
419        let t = compute_type_ix_timing(3840, 2160, RefreshRate::integral(60), CvtAlgorithm::CvtR2)
420            .expect("CVT-RB v2 must produce a timing");
421        assert_eq!(t.pixel_clock_khz, 522_614);
422        assert_eq!(t.h_total, 3920);
423        assert_eq!(t.v_total, 2222);
424    }
425
426    #[test]
427    fn cvt_rb_v2_zero_width_returns_none() {
428        assert!(
429            compute_type_ix_timing(0, 1080, RefreshRate::integral(60), CvtAlgorithm::CvtR2)
430                .is_none()
431        );
432    }
433
434    #[test]
435    fn cvt_rb_v2_unreachable_refresh_returns_none() {
436        // Frame period below the 460 µs RB v2 minimum.
437        assert!(
438            compute_type_ix_timing(1920, 1080, RefreshRate::integral(3000), CvtAlgorithm::CvtR2)
439                .is_none()
440        );
441    }
442
443    #[test]
444    fn cvt_standard_returns_none() {
445        // Standard CVT (no reduced blanking) has no evaluator implemented.
446        assert!(
447            compute_type_ix_timing(1920, 1080, RefreshRate::integral(60), CvtAlgorithm::Cvt)
448                .is_none()
449        );
450    }
451
452    #[test]
453    fn reserved_algorithm_returns_none() {
454        assert!(
455            compute_type_ix_timing(
456                1920,
457                1080,
458                RefreshRate::integral(60),
459                CvtAlgorithm::Reserved(7)
460            )
461            .is_none()
462        );
463    }
464}
465
466/// Video timing support reported in the display range limits descriptor (`0xFD`), byte 10.
467///
468/// Indicates which timing generation formula (if any) the display supports beyond the
469/// explicitly listed modes.
470#[non_exhaustive]
471#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub enum TimingFormula {
474    /// Default GTF supported (byte 10 = `0x00`).
475    ///
476    /// The display accepts any timing within its range limits that satisfies the
477    /// default GTF parameters. Requires bit 0 of the Feature Support byte (`0x18`) to be set.
478    DefaultGtf,
479    /// Range limits only; no secondary timing formula (byte 10 = `0x01`).
480    ///
481    /// The display supports only the video timing modes explicitly listed in the EDID.
482    RangeLimitsOnly,
483    /// Secondary GTF curve supported (byte 10 = `0x02`).
484    ///
485    /// The display accepts timings using either the default GTF or the secondary GTF curve
486    /// whose parameters are stored in bytes 12–17.
487    SecondaryGtf(GtfSecondaryParams),
488    /// CVT timing supported (byte 10 = `0x04`), with parameters from bytes 11–17.
489    ///
490    /// The display accepts Coordinated Video Timings within its range limits.
491    /// Requires bit 0 of the Feature Support byte (`0x18`) to be set.
492    Cvt(CvtSupportParams),
493}
494
495/// GTF secondary curve parameters decoded from a display range limits descriptor (`0xFD`).
496///
497/// Used when [`TimingFormula::SecondaryGtf`] is active (byte 10 = `0x02`).
498/// The GTF formula selects the secondary curve for horizontal frequencies at or above
499/// [`start_freq_khz`][Self::start_freq_khz] and the default curve below it.
500#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
501#[derive(Debug, Clone, PartialEq, Eq)]
502pub struct GtfSecondaryParams {
503    /// Start break frequency in kHz (byte 12 × 2).
504    pub start_freq_khz: u16,
505    /// GTF `C` parameter (0–127); byte 13 ÷ 2.
506    pub c: u8,
507    /// GTF `M` parameter (0–65535); bytes 14–15, little-endian.
508    pub m: u16,
509    /// GTF `K` parameter (0–255); byte 16.
510    pub k: u8,
511    /// GTF `J` parameter (0–127); byte 17 ÷ 2.
512    pub j: u8,
513}
514
515/// CVT support parameters decoded from a display range limits descriptor (`0xFD`).
516///
517/// Used when [`TimingFormula::Cvt`] is active (byte 10 = `0x04`).
518#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct CvtSupportParams {
521    /// CVT standard version, encoded as two BCD nibbles (e.g., `0x11` = version 1.1).
522    pub version: u8,
523    /// Additional pixel clock precision: 6-bit value from byte 12 bits 7–2.
524    ///
525    /// The maximum pixel clock is: `(descriptor byte 9 × 10 MHz) − (pixel_clock_adjust × 0.25 MHz)`.
526    /// When all six bits are set (`63`), byte 9 was already rounded up to the nearest 10 MHz.
527    pub pixel_clock_adjust: u8,
528    /// Maximum number of horizontal active pixels, or `None` if there is no limit.
529    ///
530    /// Computed as `8 × (byte 13 + 256 × (byte 12 bits 1–0))`. `None` when the 10-bit
531    /// combined value is zero.
532    pub max_h_active_pixels: Option<u16>,
533    /// Aspect ratios the display supports for CVT-generated timings.
534    pub supported_aspect_ratios: CvtAspectRatios,
535    /// Preferred aspect ratio for CVT-generated timings, or `None` for a reserved value.
536    pub preferred_aspect_ratio: Option<CvtAspectRatio>,
537    /// Standard CVT blanking (normal blanking) is supported.
538    pub standard_blanking: bool,
539    /// Reduced CVT blanking is supported (preferred over standard blanking).
540    pub reduced_blanking: bool,
541    /// Display scaling capabilities.
542    pub scaling: CvtScaling,
543    /// Preferred vertical refresh rate in Hz, or `None` if byte 17 = `0x00` (reserved).
544    pub preferred_v_rate: Option<u8>,
545}
546
547bitflags::bitflags! {
548    /// Aspect ratios supported for CVT-generated timings (byte 14 of a `0xFD` descriptor).
549    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
550    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
551    pub struct CvtAspectRatios: u8 {
552        /// 4∶3 aspect ratio supported.
553        const R4_3   = 0x80;
554        /// 16∶9 aspect ratio supported.
555        const R16_9  = 0x40;
556        /// 16∶10 aspect ratio supported.
557        const R16_10 = 0x20;
558        /// 5∶4 aspect ratio supported.
559        const R5_4   = 0x10;
560        /// 15∶9 aspect ratio supported.
561        const R15_9  = 0x08;
562    }
563}
564
565bitflags::bitflags! {
566    /// Display scaling capabilities reported in byte 16 of a `0xFD` CVT descriptor.
567    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
568    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
569    pub struct CvtScaling: u8 {
570        /// Input horizontal active pixels can exceed the display's preferred horizontal count.
571        const HORIZONTAL_SHRINK  = 0x80;
572        /// Input horizontal active pixels can be fewer than the display's preferred horizontal count.
573        const HORIZONTAL_STRETCH = 0x40;
574        /// Input vertical active lines can exceed the display's preferred vertical count.
575        const VERTICAL_SHRINK    = 0x20;
576        /// Input vertical active lines can be fewer than the display's preferred vertical count.
577        const VERTICAL_STRETCH   = 0x10;
578    }
579}
580
581/// Preferred aspect ratio for CVT-generated timings, decoded from byte 15 bits 7–5.
582#[non_exhaustive]
583#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub enum CvtAspectRatio {
586    /// 4∶3 preferred aspect ratio.
587    R4_3,
588    /// 16∶9 preferred aspect ratio.
589    R16_9,
590    /// 16∶10 preferred aspect ratio.
591    R16_10,
592    /// 5∶4 preferred aspect ratio.
593    R5_4,
594    /// 15∶9 preferred aspect ratio.
595    R15_9,
596}