culvert 0.3.0

Typed access to the HDMI 2.1 SCDC register map
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# Architecture

## Role

Culvert implements the HDMI 2.1 SCDC (Status and Control Data Channel) protocol. It sits
on top of `hdmi-hal`'s `ScdcTransport` trait and provides typed, structured access to the
SCDC register map: named fields, bitfield structs, and typed operations for scrambling
control, FRL training primitives, and CED (Character Error Detection) reporting.

The relationship to `hdmi-hal` mirrors the relationship of piaf to its input bytes.
`ScdcTransport` moves raw bytes; culvert gives those bytes meaning. The transport is
injected — culvert implements the protocol logic, the caller provides the hardware.

Culvert is a protocol primitive library, not a policy layer. It provides the typed
operations that a link training state machine needs to call. The sequencing of those
operations — when to set the FRL rate, how long to wait for `FLT_Ready`, how to handle
timeout and retry — belongs in the link training crate above.

---

## Scope

Culvert covers:

- a typed SCDC register map: named constants, bitfield structs, and typed values for all
  SCDC-defined registers,
- the `Scdc<T>` client: wraps a `ScdcTransport` and exposes typed read/write
  methods for each register group,
- scrambling control: writing `TMDS_Config`, polling `Scrambler_Status`,
- FRL training primitives: writing `Config_1` (FRL rate, FFE levels) and `Config_0`,
  reading `Status_Flags_0` (`FLT_Ready`, lane lock), the per-lane `LTP` requests and
  `Source_Test_Configuration`, reading and clearing `Update_0` (`FLT_Update`, `FRL_Start`),
- CED reporting: reading the per-lane error counters (the `ERR_DET` block, in one
  transaction, with its checksum checked) and the Reed-Solomon correction count,
- version negotiation: reading `Sink_Version`, writing `Source_Version`,
- structured errors: transport errors and protocol-level violations (e.g. an undefined
  link training pattern request from the sink) surfaced as distinct variants.

The following are out of scope:

- **Async API** — the async client lives in the separate `culvert-async` crate, which
  shares culvert's register map through [`culvert::codec`]#the-codec-module. Culvert
  carries no async surface.
- **Link training state machine** — the sequencing of FRL training (rate selection loop,
  timeout handling, retry logic, fallback to TMDS) belongs in the link training crate.
  Culvert provides the register operations; the state machine decides when to call them.
- **InfoFrame encoding** — a separate crate in the signaling layer.
- **PHY configuration** — `HdmiPhy` operations are the link training crate's concern.
- **I²C / DDC transport** — platform backends implement `ScdcTransport` from `hdmi-hal`.
  Culvert never touches I²C directly.

---

## Dependencies

```
display-types  ─┐
hdmi-hal       ─┴─►  culvert  ──►  frl-training
```

- `display-types` — for `HdmiForumFrl`, the FRL rate enum used in `Config_1`.
- `hdmi-hal` — for the `ScdcTransport` trait.

Culvert does not depend on `piaf` or `concordance`. It is consumed by the link training
crate, which sequences culvert's operations according to the FRL training algorithm.
---

## The SCDC Register Map

SCDC is defined in HDMI 2.1 spec section 10.4. Registers are one byte wide, addressed
by a one-byte offset over DDC/I²C to the sink's SCDC address (0x54).

The register map divides into six functional groups. Bit positions are given per field;
see [Sources](#sources) for how each was established.

**Version** (0x01–0x02)
- `Sink_Version` (0x01, R) — SCDC protocol version supported by the sink.
- `Source_Version` (0x02, W) — SCDC protocol version the source intends to use.

**Update flags** (0x10)
- `Update_0` (0x10, R/W, write-1-to-clear) — change notification flags set by the sink:
  `Status_Update` (bit 0), `CED_Update` (bit 1), `RR_Test` (bit 2),
  `Source_Test_Update` (bit 3), `FRL_Start` (bit 4), `FLT_Update` (bit 5),
  `RSED_Update` (bit 6). The source reads and then clears these to detect sink-side
  state changes without polling every status register on every pass. `CED_Update` and
  `RSED_Update` are set when errors occur and stay set until the source clears them, and
  a sink may never set them (see "Character Error Detection" below). culvert treats
  `RR_Test` as a flag the source must not clear: `clear_update_flags` takes
  `ClearableUpdateFlags`, which has no field for it (see "`RR_Test` is not cleared"
  below — a provisional decision resting on one source).
- `Update_1` (0x11) — no fields are defined by the sources below; culvert does not
  access it.

**TMDS and scrambling** (0x20–0x21)
- `TMDS_Config` (0x20, W) — `Scrambling_Enable` (bit 0) and `TMDS_Bit_Clock_Ratio`
  (bit 1).
- `Scrambler_Status` (0x21, R) — `Scrambling_Status` (bit 0): sink acknowledgement that
  scrambling is active.

**FRL configuration** (0x30–0x35)
- `Config_0` (0x30, W) — `RR_Enable` (bit 0, read request enable) and
  `FLT_No_Retrain` (bit 1).
- `Config_1` (0x31, W) — `FRL_Rate` (bits 3:0, maps to `HdmiForumFrl`) and
  `FFE_Levels` (bits 7:4). Written by the source to request a training rate.
- `Source_Test_Configuration` (0x35, R) — written by the sink (compliance testing) to
  instruct the source: `TxFFE_Pre_Shoot_Only` (bit 1), `TxFFE_De_Emphasis_Only` (bit 2),
  `TxFFE_No_FFE` (bit 3), `FLT_No_Timeout` (bit 5), `DSC_FRL_Max` (bit 6) and `FRL_Max`
  (bit 7). Read by the source when `Source_Test_Update` is set.

**Status** (0x40–0x42)
- `Status_Flags_0` (0x40, R) — `Clock_Detected` (bit 0), `Ch0_Locked` (bit 1),
  `Ch1_Locked` (bit 2), `Ch2_Locked` (bit 3), `Ln3_Locked` (bit 4, FRL 4-lane only),
  `FLT_Ready` (bit 6, sink ready for link training), `DSC_Decode_Fail` (bit 7).
- `Status_Flags_1` (0x41, R) — link training pattern requested for lane 0 (bits 3:0)
  and lane 1 (bits 7:4).
- `Status_Flags_2` (0x42, R) — link training pattern requested for lane 2 (bits 3:0)
  and lane 3 (bits 7:4).

  LTP request values: 0x0 = no pattern (lane trained), 0x1 = all ones, 0x2 = all zeros,
  0x3 = Nyquist clock, 0x4 = DDE compliance pattern, 0x5–0x8 = LFSR 0–3,
  0xE = request FFE change, 0xF = request FRL rate change. Other values are undefined.

**Character Error Detection** (0x50–0x5A)
- `ERR_DET_0_L/H` (0x50/0x51), `ERR_DET_1_L/H` (0x52/0x53), `ERR_DET_2_L/H`
  (0x54/0x55) — per-lane 15-bit error counters with a validity bit (bit 7) in the high
  byte. Counters are read as a pair (low + high byte) to form a single `u16` value.
- `ERR_DET_Checksum` (0x56) — makes the bytes it covers sum to zero (mod 256): 0x50–0x56
  for TMDS and 3-lane FRL, 0x50–0x58 (lane 3 included) for 4-lane FRL.
- `ERR_DET_3_L/H` (0x57/0x58) — lane 3 counter, same format; only populated in FRL
  4-lane mode.
- `RS_Correction_L/H` (0x59/0x5A) — Reed-Solomon correction count (FRL), same format
  as a CED counter: validity bit (bit 7) in the high byte and a 15-bit count. No checksum
  covers it.

`read_ced` reads 0x50–0x58 as one block and checks the checksum for the link's lane count
(display-types' `HdmiLanes`, re-exported: `rate.lanes()`, three for TMDS); a mismatch is a warning, not an error (see "Error Handling"). The coverage
follows three implementations that agree: the Amlogic transmitter drivers, the Linux
kernel's SCDC helper (`drm_scdc_helper.c`, 2026) and edid-decode; a patent of LG's
supports the TMDS range. How the counters behave, from what public sources show (no
source defines all of it):

- They accumulate and saturate at 0x7FFF (a receiver's flag logic; an Intel errata calls
  7FFFh "full").
- They reset on power-off, on a hot plug or when the sink clears its SCDC registers. No
  source ties a reset to retraining or a rate change.
- Whether reading clears them is unknown: sources disagree. culvert's documentation
  therefore tells a caller to treat a reading lower than the previous one as a reset, and
  to count afresh after retraining, a rate change or a hot plug, which is right either
  way.
- `CED_Update` / `RSED_Update` are set by the sink and kept until the source clears them:
  the register is write-1-to-clear, and one receiver sets `CED_Update` when a counter
  rises by 4 or more in a second, or first saturates, and never clears it itself. When a
  sink sets them is not settled: one was seen with `CED_Update` set and every count zero
  (below), so a set flag does not mean there are counts. A receiver has shipped that never
  set `CED_Update` (an Intel errata). So a caller reads the counters regularly, takes the
  flag as a hint at most, and clears it before reading the counters, which is right
  however the sink sets it.
- No public source defines the validity bit beyond "valid"; a lane without it is `None`,
  unknown rather than zero.

Checked on hardware (October 2026; one sink, an LG ULTRAGEAR over amdgpu's DDC, TMDS,
scrambled): the whole map read as 32-byte block reads matched byte reads on every stable
register; the sink's checksum made 0x50–0x56 sum to zero with the three validity bits set
(0x80 × 3 + 0x80); `CED_Update` was set with every count zero, and once cleared was not
set again within five seconds on the error-free link. Why it was set is open: our own
reads cannot explain it, since the first CED read of the first run already read zero. The
counts stayed zero throughout, so clear on read and tearing could not be observed.

### Sources

The register map was originally written from a summary of the spec and was wrong in
several places (FRL configuration in `Config_0`, shifted lock bits, `FRL_Start` and the
LTP requests in the wrong registers, lane 3 CED at 0x56/0x57). The map above was rebuilt
from open-source HDMI 2.1 implementations; every field was confirmed by at least two of
them (one behavioural rule, not clearing `RR_Test`, rests on a single source and has its
own section below):

- the AMD/Xilinx HDMI 2.1 receiver and transmitter drivers (`embeddedsw`,
  `v_hdmirx1/src/xv_hdmirx1_frl.c`: SCDC field table with address, mask and shift for
  every field; LTP values in `xv_hdmirx1_frl.h`; source-side training in
  `v_hdmitx1/src/xv_hdmitx1_frl.c`),
- AMD's display driver in mainline Linux (`dc_hdmi_types.h`, `link_hdmi_frl.c`),
- the Amlogic HDMI 2.1 transmitter driver (`hdmitx21/hw/hdmi_tx_ddc.h`) and a Realtek
  receiver driver (`hdmi_scdc.h`),
- the Linux DRM SCDC helper patch series for HDMI 2.1 fields (LKML, 2026-07, v8) and the
  Intel HDMI FRL enablement series for the `xe` driver (2026-08), including its FRL link
  training sequence. Both extend the mainline `drm_scdc.h`; the `xe` series additionally
  defines `Update_0` bit 7 (`LIP_Update`, HDMI 2.2) and FRL rates above 12 Gbps, which
  culvert does not decode yet.

`FFE_Levels` is the highest TxFFE level index the source supports: 0–3 for rates up to
12 Gbps and 0–7 for faster rates, per the Intel `xe` series (`drm_scdc_config_frl`) and
AMD's display driver (`hdmi_frl_get_max_ffe_level`). A sink treats a prohibited value as 0.

### `RR_Test` is not cleared (provisional)

**Rule.** culvert never clears `Update_0` bit 2, `RR_Test` (Read Request Test).
`clear_update_flags` (and `codec::encode_clear_update_flags`) takes `ClearableUpdateFlags`,
which has every update flag but `RR_Test`, so a clear that includes it cannot be written.
`read_update_flags` still reports it in `UpdateFlags`, and `UpdateFlags::clearable()`
turns the flags just read into a clear of everything else.

**Evidence.** Unlike the field layout above, this rule rests on a single source, and
another contradicts it:

| Source | What it does with `RR_Test` |
|---|---|
| Intel `xe` HDMI 2.1 series (2026-08), `drm_scdc_clear_update_flags` | States the rule — "Read Request Test is the only update flag the source cannot clear" — and returns `-EINVAL` if asked to clear it. |
| Amlogic HDMI 2.1 transmitter, `hdmitx21/hdmi_tx_scdc.c` | Clears it with the other HDMI 2.0 update flags it has read (`HDMI20_UPDATE_FLAGS & data`, which includes `READ_REQUEST_TEST`). |
| AMD display driver, `link_hdmi_frl.c` | Only ever clears specific FRL flags; no evidence either way. |
| AMD/Xilinx receiver, `xv_hdmirx1_frl.c` | Its SCDC field table has no `RR_Test`; no evidence either way. |
| Linux mainline `drm_scdc.h` | Defines the bit (`SCDC_READ_REQUEST_TEST`); has no clear helper. |

**Why culvert follows Intel.**
- It is the only source that states a rule; Amlogic's code may clear the bit only because
  it clears every flag it has seen, and a sink that owns the bit may ignore the write.
- The risks are asymmetric. If the rule holds, a source that clears `RR_Test` can break a
  sink's read-request test; if it does not, a source that leaves the flag alone loses
  nothing, as far as any source shows.
- Nothing in the stack needs to clear it: plumbob clears only `FLT_update`, `FRL_start`
  and `Source_Test_Update`.
- Nothing is dropped silently. culvert used to leave `rr_test` out of the write and
  return `Ok(())`, leaving the caller to believe the flag was cleared; with no field for
  it, the clear says exactly what is written.

**Why a separate type (October 2026).** A first version kept one `UpdateFlags` for reads
and clears and refused a clear with `rr_test` set (`ProtocolError::RrTestNotClearable`,
writing nothing). The second review of the FRL rework showed the cost: a caller that
clears what it has just read — the natural loop for an integration layer, a kernel driver
or firmware watching `Status_Update`, `CED_Update` and `FLT_update` after training — works
on every ordinary sink and fails on every clear once a sink sets `RR_Test`, which is to
say on compliance equipment, leaving `FLT_update` set. The bug would surface late, in
front of a tester. With a separate type it cannot be written: the compiler catches it,
`clearable()` names the one bit left out at the call site, and the refusal error is gone.

**Alternatives considered.** Following Amlogic and writing whatever the caller asks
(most literal register access, but lets a caller break an RR test if Intel is right); one
type for reads and clears with a run-time refusal (the first version, above); a helper
that strips `rr_test` from `UpdateFlags` (helps only callers who know to use it); keeping
the silent omission (rejected: no silent failures).

**Revisit when** any of these appears: the specification's text on `RR_Test`; a second
implementation that states or contradicts the rule (for example, a clear helper in
mainline `drm_scdc`); a sink that keeps `RR_Test` set and appears to expect the source to
clear it; or a compliance report either way. Dropping the rule means adding an `rr_test`
field to `ClearableUpdateFlags` (non-breaking for code that reads its fields, as the type
is `#[non_exhaustive]`; `new` gains a parameter, which is breaking), encoding it in
`codec::encode_clear_update_flags`, and updating this section.

---

## The `Scdc<T>` Client

The central type is a thin client struct that owns the transport and exposes typed
methods grouped by register function:

```rust
pub struct Scdc<T> {
    transport: T,
}

impl<T: ScdcTransport> Scdc<T> {
    pub fn new(transport: T) -> Self;
    pub fn into_transport(self) -> T;

    // Version
    pub fn read_sink_version(&mut self) -> Result<u8, ScdcError<T::Error>>;
    pub fn write_source_version(&mut self, version: u8) -> Result<(), ScdcError<T::Error>>;

    // Scrambling
    pub fn write_tmds_config(&mut self, config: TmdsConfig) -> Result<(), ScdcError<T::Error>>;
    pub fn read_scrambler_status(&mut self) -> Result<ScramblerStatus, ScdcError<T::Error>>;

    // FRL configuration
    pub fn write_config_0(&mut self, config: Config0) -> Result<(), ScdcError<T::Error>>;
    pub fn write_frl_config(&mut self, config: FrlConfig) -> Result<(), ScdcError<T::Error>>;
    pub fn read_source_test_config(&mut self) -> Result<SourceTestConfig, ScdcError<T::Error>>;

    // Status and update flags
    pub fn read_status_flags(&mut self) -> Result<StatusFlags, ScdcError<T::Error>>;
    pub fn read_ltp_requests(&mut self) -> Result<LtpRequests, ScdcError<T::Error>>;
    pub fn read_update_flags(&mut self) -> Result<UpdateFlags, ScdcError<T::Error>>;
    pub fn clear_update_flags(&mut self, flags: ClearableUpdateFlags) -> Result<(), ScdcError<T::Error>>;

    // CED
    pub fn read_ced(&mut self, lanes: HdmiLanes) -> Result<CedReading, ScdcError<T::Error>>;
    pub fn read_rs_correction(&mut self) -> Result<Option<RsCorrectionCount>, ScdcError<T::Error>>;
}
```

`Scdc<T>` holds no state beyond the transport. Register reads and writes are direct and
stateless from the client's perspective; any sequencing state lives in the caller.

Methods map to register groups, not necessarily individual registers. Three methods span
multiple registers, each read with one `ScdcTransport::read_block`:

- `read_ltp_requests()` reads `Status_Flags_1` and `Status_Flags_2` (0x41–0x42) and
  returns the pattern requested for each of the four lanes as one `LtpRequests` struct.
  The sink updates all lanes' requests together and signals the change once through
  `FLT_Update`.
- `read_ced(lanes)` reads the CED block (0x50–0x58: the four counters and the checksum)
  and returns a `CedReading`: the counters, with a warning if the checksum does not match
  or lane 3 reports a count at three lanes.
- `read_rs_correction()` reads `RS_Correction_L/H` (0x59–0x5A).

`hdmi-hal`'s `read_block` contract makes each one transaction, so the transport does not
split the registers; whether the sink holds them still during it is up to the sink. A
transport that reads one register at a time (`read_block_bytewise`) says so, and some bus
drivers split a block read on the wire anyway (the Synopsys DesignWare HDMI controller's
reads one byte at a time). The CED checksum is the one protection against a torn read
that holds on every transport, adapter and sink: it catches most torn CED reads, not all.
The LTP requests and the RS count have no checksum, so a torn read of them cannot be
detected; the transaction narrows the window, on a transport and adapter that keep it.
The tearing (0x00FF ticking to 0x0100 read back as 0x01FF) is kept as regression tests,
`tests/torn_reads.rs`. None of this is a multi-step sequence (write
rate, poll for ready, handle pattern request): those belong in the link training crate.

---


## The `codec` Module

`culvert::codec` holds the register map without any I/O: the address of every register
culvert accesses, `CED_BLOCK_START` and `CED_BLOCK_LEN` for the CED block, and one
function per register that encodes a typed value into a byte or decodes bytes into a typed
value — `encode_config_1`, `decode_ltp_requests`, `decode_ced_block`, and so on.
Protocol checks live there too: `encode_config_1` rejects FFE levels above the rate's
maximum as a `ProtocolError`. Decoding judges nothing it cannot judge: `decode_ltp_requests`
decodes every value, the undefined ones (0x9–0xD) as `LtpReq::Reserved(value)`, because
whether one matters depends on the lanes in use, which only the link training layer
knows. Nor does it withhold anything: `decode_ced_block` checks the checksum and returns
the decoded counters with a typed warning when it fails, leaving the decision to the
caller.

`Scdc<T>`'s methods only perform the reads and writes and hand the bytes to `codec`.
`culvert-async`'s client does the same with `.await`, so the sync and async clients share
one register map: an address or bit-layout fix in `codec` reaches both. The stack design
document's "Sync and Async Companions" section records why the shared part lives in
culvert.

---

## Key Types

```rust
pub struct TmdsConfig {
    pub scrambling_enable: bool,
    pub high_tmds_clock_ratio: bool,  // false = /10, true = /40
}

pub struct ScramblerStatus {
    pub scrambling_active: bool,
}

/// `Config_0` (0x30).
pub struct Config0 {
    pub rr_enable: bool,        // bit 0: sink may raise read requests
    pub flt_no_retrain: bool,   // bit 1
}

/// FFE (Feed-Forward Equalization) levels written into `Config_1` bits[7:4]: the highest
/// TxFFE level index the source supports. `FfeLevels::new` rejects values above 7, and
/// `FfeLevels::max_for(rate)` gives the per-rate maximum (3 up to 12 Gbps, 7 above), which
/// `write_frl_config` enforces.
pub struct FfeLevels(u8);

/// `Config_1` (0x31).
pub struct FrlConfig {
    pub frl_rate: HdmiForumFrl,   // from display-types; bits[3:0]
    pub ffe_levels: FfeLevels,    // bits[7:4]
}

/// `Source_Test_Configuration` (0x35), written by the sink.
pub struct SourceTestConfig {
    pub txffe_pre_shoot_only: bool,     // bit 1
    pub txffe_de_emphasis_only: bool,   // bit 2
    pub txffe_no_ffe: bool,             // bit 3
    pub flt_no_timeout: bool,           // bit 5
    pub dsc_frl_max: bool,              // bit 6
    pub frl_max: bool,                  // bit 7
}

/// Link Training Pattern requested by the sink for one lane (a 4-bit field in
/// `Status_Flags_1`/`Status_Flags_2`). `LtpReq::value()` returns the 4-bit value.
pub enum LtpReq {
    None,              // 0x0: lane trained, no pattern requested
    AllOnes,           // 0x1
    AllZeros,          // 0x2
    NyquistClock,      // 0x3
    DdeCompliance,     // 0x4
    Lfsr0,             // 0x5
    Lfsr1,             // 0x6
    Lfsr2,             // 0x7
    Lfsr3,             // 0x8
    FfeChange,         // 0xE: sink requests an FFE level change
    RateChange,        // 0xF: sink requests a lower FRL rate
    Reserved(u8),      // 0x9–0xD: undefined by the specification
}

/// Per-lane pattern requests from `Status_Flags_1` (0x41) and `Status_Flags_2` (0x42).
pub struct LtpRequests {
    pub lane0: LtpReq,
    pub lane1: LtpReq,
    pub lane2: LtpReq,
    pub lane3: LtpReq,   // LtpReq::None in 3-lane FRL
}

/// `Status_Flags_0` (0x40).
pub struct StatusFlags {
    pub clock_detected: bool,    // bit 0
    pub ch0_locked: bool,        // bit 1
    pub ch1_locked: bool,        // bit 2
    pub ch2_locked: bool,        // bit 3
    pub ln3_locked: bool,        // bit 4, FRL 4-lane only
    pub flt_ready: bool,         // bit 6: sink ready for link training
    pub dsc_decode_fail: bool,   // bit 7
}

/// `Update_0` (0x10), write-1-to-clear.
pub struct UpdateFlags {
    pub status_update: bool,        // bit 0
    pub ced_update: bool,           // bit 1
    pub rr_test: bool,              // bit 2
    pub source_test_update: bool,   // bit 3
    pub frl_start: bool,            // bit 4: training passed, source may start FRL
    pub flt_update: bool,           // bit 5: LTP requests changed
    pub rsed_update: bool,          // bit 6
}

/// The `Update_0` flags a source may clear: every flag but `RR_Test`.
/// `UpdateFlags::clearable()` gives the flags just read in this form.
pub struct ClearableUpdateFlags {
    pub status_update: bool,        // bit 0
    pub ced_update: bool,           // bit 1
    pub source_test_update: bool,   // bit 3
    pub frl_start: bool,            // bit 4
    pub flt_update: bool,           // bit 5
    pub rsed_update: bool,          // bit 6
}

/// A 15-bit character error count decoded from an ERR_DET register pair.
/// The high byte's bit 7 is the validity flag; the counter occupies bits[14:0].
/// `CedCount::value()` returns the raw count as a `u16` (always <= 0x7FFF).
pub struct CedCount(u16);

/// A 15-bit Reed-Solomon correction count from RS_Correction_L/H (0x59/0x5A), same
/// format as a CED counter.
pub struct RsCorrectionCount(u16);

pub struct CedCounters {
    pub lane0: Option<CedCount>,   // None if validity bit not set
    pub lane1: Option<CedCount>,
    pub lane2: Option<CedCount>,
    pub lane3: Option<CedCount>,   // None in TMDS / 3-lane FRL mode
}

/// The link's data lanes, re-exported from display-types (`FrlRate::lanes()`, total:
/// `NotSupported`, TMDS, is `Three`). With `Three` the checksum covers 0x50–0x56, with
/// `Four` 0x50–0x58.
pub use display_types::HdmiLanes; // enum HdmiLanes { Three, Four }

/// One CED read: the counters, decoded whatever the warnings say, and the warnings,
/// stored privately (`warnings()`, `iter_warnings()`).
pub struct CedReading {
    pub counters: CedCounters,
    // private: the warnings
}

pub enum CedWarning {
    /// The covered bytes do not sum to zero: most likely a torn read.
    ChecksumMismatch { expected: u8, found: u8 },
    /// Lane 3 reports a count with `HdmiLanes::Three`; `lane3` stays `None`.
    UnusedLaneReported { count: CedCount },
}
```

All output structs are `#[non_exhaustive]` for forward compatibility, and so are
`CedWarning` and each of its variants. `HdmiLanes` is display-types' and exhaustive: every
HDMI version that defines FRL has three or four lanes.

---

## Error Handling

Culvert surfaces two distinct failure categories:

```rust
#[non_exhaustive]
pub enum ScdcError<E> {
    /// The underlying I²C/DDC transport returned an error.
    Transport(E),
    /// The operation would violate the SCDC protocol (e.g. FFE levels the rate
    /// prohibits); nothing was written.
    Protocol(ProtocolError),
}

#[non_exhaustive]
pub enum ProtocolError {
    UnknownFrlRate(u8),   // reserved; no current method reads an FRL rate back
    FfeLevelsOutOfRange { rate: FrlRate, levels: u8 },
}
```

This mirrors the pattern established in piaf: transport failures and protocol violations
are distinct. A caller that only cares about transport health can match on `Transport(_)`;
`Protocol(_)` means culvert refused a request that would break the protocol. Every value a
sink can report decodes — an undefined link training request is `LtpReq::Reserved` — so
unexpected sink behaviour reaches the caller as data, not as an error.

Both enums are `#[non_exhaustive]` at the type level, consistent with the rest of the
stack. Tuple variants are plain — callers can match `UnknownFrlRate(value)` without `..`.

**Warnings.** One read finds something wrong without refusing anything: the CED checksum.
A mismatch is a `CedWarning` in the `CedReading`, next to counters decoded anyway, never
an error, following the stack's rule for checksums ("a checksum mismatch on decode is a
structured warning, not an error — the decoded struct is still returned and the caller
decides"), which cartouche follows for InfoFrames. An error would leave no counts at all
for a sink whose checksum does not conform, and the decoding layer judges nothing: whether
to distrust the counts, or read again, is the caller's. The warnings are stored privately
(`warnings()`, `iter_warnings()`), as the stack's warning storage rule requires, so a
later warning can be added without breaking anything; at most one of each kind occurs per
read. `ProtocolError` and `ScdcError` keep their meaning: requests culvert refuses, and
transport failures.

`ScdcError` is `#[non_exhaustive]` to allow future variants without a breaking change.

---

## The Culvert / Link Training Boundary

This boundary is worth stating explicitly because the SCDC spec interleaves protocol
mechanics and training algorithm steps.

**Culvert's responsibility:** typed register access. Given a desired FRL rate, write it
into `Config_1`. Given a status register, decode it into `StatusFlags`. Culvert does not
know what to do with a `StatusFlags`; it only knows how to read one.

**Link training's responsibility:** the state machine. Receive a ranked list of FRL tiers
from concordance. For each tier: wait for `FLT_Ready`, write `Config_1`, then on each
`FLT_Update` read the per-lane `LTP` requests and act on them (send the pattern, change
FFE, or drop the rate) until every lane reports no pattern, then wait for `FRL_Start`. That sequencing logic, timeout
handling, and retry policy live in the link training crate — not here.

The rule: if it touches time, state across multiple register accesses, or fallback logic,
it belongs in link training. If it reads or writes registers and returns typed results, it
belongs in culvert.

---

## The `plumbob` Feature

culvert implements `plumbob::ScdcClient` for `Scdc<T>` behind a `plumbob` cargo feature.
This follows the same convention as `serde` feature flags in the ecosystem: the producing
crate reaches toward the consuming crate's trait, rather than the consumer depending on
the producer.

```sh
# a crate using both
cargo add culvert --features plumbob
cargo add plumbob
```

Each `ScdcClient` method calls one culvert method:

| `ScdcClient` method | culvert method | Register |
|---|---|---|
| `read_flt_ready` | `read_status_flags().flt_ready` | `Status_Flags_0` (0x40) bit 6 |
| `read_update_flags` | `read_update_flags` | `Update_0` (0x10), all seven flags |
| `clear_update_flags` | `clear_update_flags` | `Update_0` (0x10), write 1 to clear |
| `read_ltp_requests` | `read_ltp_requests` | `Status_Flags_1/2` (0x41/0x42) |
| `read_source_test_config` | `read_source_test_config` | `Source_Test_Configuration` (0x35) |
| `write_config_0_defaults` | `write_config_0(Config0::default())` | `Config_0` (0x30) |
| `write_frl_config` | `write_frl_config` | `Config_1` (0x31) |

plumbob's `ScdcClient` has no CED method: training never reads the counters, so a link
health monitor reads them from `Scdc<T>` directly (`read_ced`, `read_rs_correction`).

`From` impls in the feature-gated module convert culvert's types to plumbob's owned types
(`LtpReq`, `LtpRequests`, `UpdateFlags`, `SourceTestConfig`)
and plumbob's `ClearableUpdateFlags` and `FrlConfig` back for the writes. culvert's own types are
unchanged, so culvert means the same thing with or without the feature. plumbob's
`ScdcClient` types mirror culvert's field for field (the same names, every field of each
register, the same constructors and `#[non_exhaustive]`; each crate adds methods of its
own), so every field crosses the boundary and the `From`
impls are field-for-field copies, tested bit by bit. plumbob clears only the flags it acts
on (`Source_Test_Update`, `FRL_start`, `FLT_update`); culvert's `StatusFlags` has no
plumbob counterpart and stays available through `Scdc<T>` directly.
`write_frl_config` keeps culvert's own FFE-levels check, which plumbob's per-rate limit
already satisfies.

**Polling and stopping early.** `Scdc` holds no state and does not wait between calls.
plumbob's limits assume one poll every 2 ms by default, and plumbob's contract puts the
wait inside the two polled methods, `read_flt_ready` and `read_update_flags`. With
`Scdc<T>` that means an `ScdcClient` of your own that wraps it, waits in those two
methods and forwards the rest:

```rust
use culvert::{Scdc, ScdcError, ScdcTransport};
use plumbob::{ScdcClient, UpdateFlags};
use std::time::Duration;

/// `Scdc<T>` that waits the poll interval in the two methods plumbob polls.
struct Paced<T>(Scdc<T>);

impl<T: ScdcTransport> ScdcClient for Paced<T> {
    type Error = ScdcError<T::Error>;

    fn read_flt_ready(&mut self) -> Result<bool, Self::Error> {
        std::thread::sleep(Duration::from_millis(2)); // or the platform's timer
        ScdcClient::read_flt_ready(&mut self.0)
    }

    fn read_update_flags(&mut self) -> Result<UpdateFlags, Self::Error> {
        std::thread::sleep(Duration::from_millis(2));
        ScdcClient::read_update_flags(&mut self.0)
    }

    // … the other five methods forward without waiting
}
```

A wall-clock deadline or a cancel signal goes in the same two methods, following
plumbob's "Stopping early" rule (`plumbob::ScdcClient`): return one error of your own
(the wrapper's `Error` then wraps `ScdcError`), let every later call through, and never
fail a read made after a `write_frl_config` with `NotSupported` until a `write_frl_config`
with an FRL rate starts training again. Neither belongs in the transport: a wait there
applies to every register access, not only the polls, and a transport cannot tell the
polled reads from LTS:L's, so a deadline there can fail LTS:L itself and leave the sink in
FRL.

---

## `no_std` Compatibility

Culvert requires no allocator. All output types are stack-allocated structs. The
`ScdcError<E>` type requires no heap. `Scdc<T>` holds only the transport, which is
caller-owned.

The full API is available in bare `no_std` environments. CI builds the crate, with and
without the `plumbob` feature, for `thumbv7em-none-eabi`, a target without `std`.

---

## Design Principles

- **Typed access, not raw bytes.** Every register read returns a named struct, not a raw
  `u8`. Every register write takes a typed config, not a bit pattern. Culvert is the
  translation layer between the wire format and the rest of the stack.
- **Interface owned by the consumer.** The `ScdcClient` trait that culvert implements is
  defined in `plumbob`, not here. culvert implements the trait; it does not define it.
  This means the link training layer can swap culvert for any other `ScdcClient`
  implementation without touching culvert. The `plumbob` cargo feature gates the impl
  so culvert remains independently usable without the link training layer as a dependency.
- **Spec accuracy and completeness.** Every field culvert decodes is checked against the
  sources listed with the register map. No register is omitted because its consumer has
  not been built yet; registers not wrapped yet (`Update_1`, `Test_Config_0`,
  manufacturer identification) are tracked on the roadmap.
- **One register map.** Every address, bit position and protocol check is in `codec`,
  shared by the sync and async clients; the clients add only the I/O.
- **Stateless client, stateful caller.** `Scdc<T>` holds no protocol state. Sequencing,
  retry logic, and training state live in the caller. This keeps culvert fully testable
  in isolation — any sequence of register reads and writes can be exercised without
  simulating a training run.
- **Deterministic and testable.** The simulated transport pattern from `hdmi-hal` applies
  here: pre-load a register array, run culvert operations against it, assert on results.
  No hardware required.
- **Transport errors and protocol errors are distinct.** A caller should be able to tell
  whether a failure came from the I²C bus or from a request culvert refused.
- **Stack-ordered delivery.** The 0.1.0 scope is the register coverage needed by the
  link training crate. Everything else the spec defines is on the roadmap.
- **No unsafe code.** `#![forbid(unsafe_code)]`.
- **Attested releases.** Every release is published through a GitHub Actions workflow
  that signs the `.crate` package with [SLSA Build Level 2]https://slsa.dev provenance.
  Verify with `gh attestation verify <file> --repo ashlar-display/culvert` (the release from
  before the move to the `ashlar-display` organization: see "Verifying releases" in the
  README).