Personal project · Jul 2026 – present
HDMI / DVI transmitter
A DVI transmitter written from scratch in Verilog, verified in simulation before it ever touches a board.
Four stages
The transmitter is a DVI video pipeline for the HDMI connector on a PYNQ-Z2, written from scratch in Verilog and started in July 2026 — plain DVI, with no audio and no data islands: three TMDS channels and a clock. It has four stages: a parameterized video timing generator, a pattern generator, a TMDS 8b/10b encoder, and a 10:1 serializer driving differential outputs. The first three exist and pass their testbenches; the serializer’s port list exists, and its body is still being written.
The timing generator is parameterized on the eight standard numbers and derives the rest as localparams. It produces 640×480 active pixels inside an 800×525 total frame — 16 front porch, 96 sync and 48 back porch clocks on every line; 10, 2 and 33 lines around every frame — at a 25.175 MHz pixel clock, and outputs hsync, vsync, de and 12-bit x/y counters. The pattern generator is deliberately simple for now: a horizontal red ramp, red = x mod 256, green and blue zero, black outside de.
The encoder
TMDS encoding is done in two stages, kept separate in the RTL so each can be reasoned about alone. The first counts the ones in the byte and chooses XOR or XNOR chaining to minimize transitions, flagging the choice in bit 8. The second is a DC-balance stage: it keeps a signed running disparity, cnt_r, and decides whether to invert the eight data bits to pull it back toward zero, flagging that in bit 9.
During blanking the encoder ignores the data, emits one of the four DVI control codewords selected by c0/c1 (Fig. 2), and resets the disparity to zero. The disparity arithmetic is where I expected mistakes, so that is where the testbench looks hardest.
Try the encoder
Fig. 3 is the encoder itself, ported line for line from the RTL to JavaScript so it can be driven from the page. Pick a byte and the figure shows what each stage does with it: the ones count and the XOR or XNOR choice, the nine-bit q_m, the DC-balance decision with the reason the RTL used, and the ten bits that leave the encoder, drawn q[0] first as in Fig. 2. Set a prior disparity to reach the inversion branch, or feed the ramp to watch cnt_r accumulate over a video line the way it does for the red-ramp pattern, reset to zero in blanking. Under the figure is a self-check that runs in the reader’s browser every time this page loads: all 256 bytes through the encoder and back through a decoder written from the testbench’s, with the transition count and the disparity bound checked over one full line.
self-check: not run — this line needs JavaScript
What the testbenches check
The encoder has a self-checking XSIM testbench that checks four properties on every symbol: round trip — decode(encode(d)) == d against an independently written decoder; at most five transitions per data symbol; an independently accumulated running disparity, kept from the ten transmitted bits, that matches the encoder’s own cnt_r every cycle; and a bounded |disparity|, with the bench’s limit at 12. Control periods are checked against the four spec codewords. One run covers 10,816 data symbols: every byte value ascending and descending, each value held for eight cycles to provoke disparity drift, long runs of four constants, and forty random bursts with blanking between them.
A second testbench drives the timing generator and the pattern generator together, waits for de at x = 0, y = 0, and dumps the full 640×480 frame from simulation to a PPM file so it can be checked numerically rather than by eye. Alongside it, it checks seven timing counts against constants: active pixels per frame, active lines, hsync pulses, total hsync and vsync cycles, and the length of every de run and every hsync pulse. Fig. 4 is that frame, placed inside the 800×525 timing envelope it was generated in.

blanking 160
blanking 45 lines (10 / 2 / 33)
Fig. 5 lays the pipeline out with its coverage. The frame testbench brackets the first two stages, the encoder testbench the third, and the serializer is drawn dashed because its body is not yet written.
What is not done
Timing, pattern and encoder are verified in simulation. The 10:1 serializer — port list written, body not yet — and board bring-up are in progress. Nothing has been displayed on a monitor, and the page will say so until it has.
- donetiming generator
- donepattern generator
- doneTMDS encoder + self-checking testbench
- in progress10:1 serializer
- in progressboard bring-up