A programmable 15kHz video card in an FPGA. It streams blits rather than buffering frames.
Author: Ben Templeman (alphanu1)
BlitCRT turns a Cyclone IV FPGA (Waveshare CoreEP4CE10 on a DVK600) into a 15kHz arcade-monitor video card driven over a USB FIFO. It speaks the CRT1 protocol, the same wire protocol as CRTPi, the Raspberry Pi 4 version of this idea (github.com/alphanu1/rpi-video-card). GroovyMAME and RetroArch (MME4CRT) via switchres drive either device with the same host code:
MME4CRT / switchres -> CRT1 -> BlitCRT (FT2232H FIFO)
-> Pi2SCART DAC -> 15kHz CRT
BlitCRT is the device (the FPGA video card). CRT1 is the wire protocol, which CRTPi speaks too. CRTPi does the same job on a Raspberry Pi 4 instead of an FPGA; both present as a USB video card for 15kHz CRTs.
On power-up, and until the first frame arrives, BlitCRT paints a test card from the timing generator alone. No host, no RAM:
It shows SMPTE colour bars, a 1px white border, and a two-line readout of
the current resolution and the measured vertical refresh and pixel clock,
e.g. 320x240 / 60.01HZ 6.400MHZ. The refresh and clock are measured
on-device against the 50MHz crystal (rtl/mode_meter.v), not taken from the
host. They reflect the achieved timing, including PLL quantization.
Switching resolutions over UART re-renders the card with updated numbers
on each SET_MODE. A mode can be confirmed on the CRT this way without ever
streaming a frame. The card gives way to live video on the first
CMD_FRAME.
These images are rendered from the RTL. A testbench runs the video_timing_prog and splash_pattern modules, captures the RGB value the design drives at every active pixel of a frame, and writes it to a PNG. Three modes are shown. The readout tracks each resolution and its measured fractional refresh (59.94, 50.17) and pixel clock.
| 320x240 @ 60.01Hz | 256x224 @ 59.94Hz | 384x288 @ 50.17Hz |
|---|---|---|
![]() |
![]() |
![]() |
Regenerate with sim/tb_dump.v (see docs/VIDEOCARD_V2.md).
Timing diagrams from the same simulation. sim/tb_wave.v dumps a VCD of video_timing_prog and splash_pattern at the 6.4MHz pixel clock.
Scanline: data-enable, the RGB bus stepping through the bars, and the hsync pulse in blanking. One line is 63.6us, a 15.7kHz line rate.
Frame: the hsync train, one pulse per line, with a single vsync pulse per frame. Vsync is every 16.66ms, i.e. 60Hz.
BlitCRT holds no frame queue. The host does not hand it whole frames to buffer and display later. Instead the host pushes damage rectangles over CRT1 (a CMD_FRAME with x/y/w/h and pixels), and BlitCRT writes those bytes into the live scanout buffer as they arrive. The timing generator reads that same buffer out to the CRT continuously. Writes land in the region being scanned out, in place. There is no separate present or flip step.
Latency is roughly the wire-transfer time, about 6.6ms over the sync FIFO. That is under half a frame, versus the one or two frames a buffered path adds. Partial updates are cheap. A game touching a quarter of the screen sends a quarter of the bytes, because damage rectangles are part of the protocol. There is no reordering or queue management to reason about.
A classic framebuffer card writes a full frame to off-screen memory, then a flip at vsync swaps it in. That adds at least one frame of latency and needs double-buffering.
rtl/ Verilog source
fb_top_v2.v top level
crt1_ft245.v CRT1 packet engine (SET_MODE/SET_PLL/FRAME/PAL)
ft245_rx.v FT245 async FIFO read engine
ft245_tx.v FT245 async FIFO write engine (reply path)
uart_rx.v UART byte source for bring-up
video_timing_prog.v runtime-programmable timing generator
framebuffer.v 4bpp dual-clock line/scanout buffer
palette.v 16 x RGB444 lookup
splash_pattern.v test card (bars, border, res/Hz/clock readout)
mode_meter.v measures refresh + pixel clock from frame period
pll_ctl.v runtime pixel-clock reconfiguration sequencer
sim/ iverilog testbenches
quartus/ pins_pi2scart.tcl, SDC, templates
host/ C client (crt1.c/.h, crt1_ftdi.c), Python, ref_sweep.py
integration/ MME4CRT reference backend
docs/ VIDEOCARD_V2, QUARTUS_BUILD, PI2SCART_OUTPUT
The -wired bundle adds the generated Quartus project and the two ALTPLL megafunctions to the same tree. It compiles without regenerating IP.
12-byte header (magic 0x31545243 "CRT1", cmd, flags, seq, len LE) then a command payload. Host to device: GET_INFO, SET_MODE (32-byte wire modeline, switchres position semantics), SET_PLL (m/n/c dividers computed host-side), FRAME (x/y/w/h damage rect and pixels), SET_PAL. Device to host: EVT_INFO, EVT_MODE_RESULT (achieved clock readback), EVT_STATUS. Full spec in docs/ here, and in CRTPi's docs/PROTOCOL.md (github.com/alphanu1/rpi-video-card), which defines the same wire format.
- Video out: RGB666 plus separate H/V sync on DVK600 32I/Os_2 holes 1..20, into a Pi2SCART resistor-ladder DAC. See docs/PI2SCART_OUTPUT.md for the pin/ball/ribbon map and the 5V safety note.
- Host link, selected by the USE_UART build parameter:
- USE_UART=1 (default): CRT1 bytes over a plain USB-serial adapter (FTDI/CP2102/CH340) on one pin (8I/Os_1 hole 1, PIN_F11), 2Mbaud = 0.2MB/s. This is for bring-up and resolution-change testing, not video streaming. A mode switch (SET_PLL+SET_MODE, ~0.65ms) and a static test frame go through fine. A full 320x240 4bpp frame takes ~192ms (about 5fps), too slow for live emulator output.
- USE_UART=0: FT2232H FT245 FIFO on 32I/Os_2 holes 21..32. Async today (~1MB/s), enough for 60Hz partial-frame updates from MME4CRT. The sync FIFO (~40MB/s) adds headroom for full-frame 60Hz and, later, direct-colour host feeds (RGB565/RGB888, see Future development). Both links are assigned to distinct pins, so a build can select either by the USE_UART parameter without changing pins. They feed the same packet engine. Nothing downstream changes.
- Clock: on-board 50MHz oscillator (PIN_E16). Reset on PIN_B16.
All signals land on the DVK600 32I/Os_2 bank, which the CoreEP4CE10 H_Up header feeds straight through. Hole numbers below are the silkscreen labels on the header. Wire the ribbon from those holes.
signal hole EP4CE10 Pi2SCART target
vid_r6[5] 1 PIN_R11 R7 (Red MSB)
vid_r6[4] 2 PIN_N12 R6
vid_r6[3] 3 PIN_P11 R5
vid_r6[2] 4 PIN_N11 R4
vid_r6[1] 5 PIN_P9 R3
vid_r6[0] 6 PIN_N9 R2 (Red LSB)
vid_g6[5] 7 PIN_R10 G7 (Green MSB)
vid_g6[4] 8 PIN_T11 G6
vid_g6[3] 9 PIN_R9 G5
vid_g6[2] 10 PIN_T10 G4
vid_g6[1] 11 PIN_R8 G3
vid_g6[0] 12 PIN_T9 G2 (Green LSB)
vid_b6[5] 13 PIN_R7 B7 (Blue MSB)
vid_b6[4] 14 PIN_T8 B6
vid_b6[3] 15 PIN_R6 B5
vid_b6[2] 16 PIN_T7 B4
vid_b6[1] 17 PIN_R5 B3
vid_b6[0] 18 PIN_T6 B2 (Blue LSB)
vid_hs_n 19 PIN_R4 HSync
vid_vs_n 20 PIN_T5 VSync
The Pi2SCART's RGB pins are not in numeric order on its connector. Each colour bit goes to a specific pin. docs/PI2SCART_OUTPUT.md lists the CN3 pin per bit.
The UART and the FT245 FIFO are on separate pins. One build can pick either link with the USE_UART parameter, with no pin change. Wire only the one the build uses.
UART (USE_UART=1 default), one wire from a USB-serial adapter, on its own pin outside the 32I/Os_2 bank:
signal header/hole EP4CE10 wire to
uart_rx_pin 8I/Os_1 hole 1 PIN_F11 USB-serial TX (plus GND to board GND)
FT245 FIFO (USE_UART=0), 32I/Os_2 holes 21..32:
signal hole EP4CE10 FT2232H (channel A, BDBUS)
ft_data[0] 21 PIN_R3 D0
ft_data[1] 22 PIN_T4 D1
ft_data[2] 23 PIN_M9 D2
ft_data[3] 24 PIN_T3 D3
ft_data[4] 25 PIN_K9 D4
ft_data[5] 26 PIN_L9 D5
ft_data[6] 27 PIN_L8 D6
ft_data[7] 28 PIN_K8 D7
ft_rxf_n 29 PIN_M7 RXF#
ft_txe_n 30 PIN_M8 TXE#
ft_rd_n 31 PIN_M6 RD#
ft_wr_n 32 PIN_L7 WR#
System: clk50 = PIN_E16 (on-board osc), rst_n = PIN_B16 (RESET key).
The UART now has its own pin (PIN_F11) and no longer shares a hole with ft_data[0]. Still run one link at a time: do not connect a USB-serial adapter and an FT2232H together. On the Pi2SCART side, never wire its 5V pins (header 2/4) to any FPGA pin. The I/O is 3.3V.
Frames are streamed in the native 4bpp packed format (a 16-colour palette index per pixel; the palette expands to the RGB666 DAC output). A 320x240 frame is 38.4KB, so 60Hz needs 2.3MB/s. The links:
link rate 320x240 4bpp frame 4bpp 60Hz full-frame?
UART 2Mbaud 0.2 MB/s 192 ms (~5 fps) no
FT245 async ~1 MB/s 38 ms (~26 fps) no, but partials fine
FT245 sync FIFO ~40 MB/s ~1 ms yes, wide margin
A mode change is small: SET_PLL+SET_MODE is ~196 bytes, 0.65ms over UART. UART is therefore fine for switching resolutions and painting a static frame, but too slow for live video. The FT2232H sync FIFO carries full 4bpp frames at 60Hz with margin to spare (2.3MB/s needed against ~40MB/s). Async FT245 (~1MB/s) handles partial-frame (damage-rect) updates at 60Hz and full frames at a reduced rate, but not full-frame 60Hz. Direct-colour host feeds raise the bar: RGB565 is 9.2MB/s and RGB888 18.4MB/s at 60Hz, both firmly in sync-FIFO territory (and gated on external RAM, see Future development). Output is RGB666 either way.
PLL quantization is under 10.3ppm across the arcade clock battery. The reference oscillator's own tolerance dominates. The suggested upgrade is a +/-1ppm 50MHz TCXO. The target frequency choice is under 1ppm either way (see host/ref_sweep.py). Exact synthesis via an Si5351A is a later option. Details in docs/VIDEOCARD_V2.md.
- Generate the two ALTPLL megafunctions in Quartus (docs/QUARTUS_BUILD.md), or use the -wired bundle which already has them.
source quartus/pins_pi2scart.tclin the Tcl console.- Add an SDC (create_clock 50MHz, derive_pll_clocks; quartus/fb_top.sdc).
- Start Compilation, then flash the .sof over JTAG.
- Power on. The test card appears on the CRT with no host attached. That covers PLL lock, timing, the DAC and the ribbon in one step.
- To test resolution switching from the PC (USE_UART=1): wire a USB-serial adapter's TX to 8I/Os_1 hole 1 (PIN_F11) and GND to board GND, then run host/crt1_uart_test.py PORT . This confirms mode switching and a painted frame. It is not fast enough for live video. For 60Hz video, build with USE_UART=0 and use the FT2232H sync-FIFO path.
More simultaneous colours. The DAC output is already RGB666 (18-bit). Each pixel is currently a 4-bit index into a 16-entry palette. Only 16 colours appear at once. A direct-colour mode stores full colour per pixel and drops that limit: each pixel holds RGB666 directly, or a host feed in RGB565/RGB888 reduced to RGB666 at output. The FT245 sync FIFO has the bandwidth. A direct-colour frame is 3 bytes/pixel: 13.8MB/s at 320x240 60Hz, 19.9MB/s at 384x288, both under the ~40MB/s ceiling.
What gates it is on-chip RAM. The scanout buffer is currently EP4CE10 block RAM (~414 Kbit total). 320x240 at 4bpp is 300 Kbit and fits. A direct-colour frame at 3 bytes/pixel is 1800 Kbit (320x240) or 2650 Kbit (384x288). Both are well over budget and need external memory. The CoreEP4CE10 has no onboard SDRAM. The fix is Waveshare's SDRAM Board (B) on the DVK600 (H57V1262GTR, 8M x 16-bit = 16 MB), or an equivalent, plus an SDRAM controller and the clock-domain work to feed scanout from it. The controller is the work. Capacity is not a concern. A single frame is 225 KB (320x240) or 324 KB (384x288), against 16 MB on the board, room for around 50 frames.
Higher resolutions need the same external memory, and width triggers it before colour depth does. 640x480 60i is a valid 15kHz mode: it is two 640x240 fields woven together at 60 fields per second. The line rate stays ~15.7kHz, the same as 320x240 240p, which is what lets a 15kHz CRT display it. The pixel clock roughly doubles to ~12.6MHz for the extra horizontal pixels, which is an easy PLL target, and the bandwidth is fine (4.6MB/s at 4bpp, 27.6MB/s direct-colour, both within the sync FIFO). The blocker is buffer RAM. A single 640-wide field at 4bpp is 600 Kbit, already over the ~414 Kbit on-chip budget. Any 640-wide mode needs SDRAM even at 4bpp and even interlaced. In SDRAM it is small: a 640x480 4bpp frame is 150 KB, direct RGB666 is 900 KB, against 16 MB on the board. The timing generator already takes arbitrary modelines. Interlace adds a field toggle in the vertical counter. The work is the SDRAM path, not the timing.
Adding SDRAM does not turn BlitCRT into a framebuffer. The blit-streamer model is defined by the write path, not by where the pixels live: the host sends damage rectangles, the device writes them into the live scanout region, and scanout reads the same memory continuously, with no present or flip step. A single RGB666 frame in SDRAM keeps exactly that model and the same latency, just with full per-pixel colour. Double-buffering (450 KB, still a fraction of the board) is an option later if tearing on large fast updates becomes visible. It copies dirty regions rather than presenting a whole frame, and stays a blit streamer. A true framebuffer with a vsync flip would mean dropping the damage-rect model and the low-latency behaviour, and is not planned.
The Pi2SCART DAC stays at RGB666 (6 bits per channel). A host sending RGB888 is truncated or dithered to RGB666 at output. True 8-bit-per- channel output would need a wider DAC than the Pi2SCART ladder.
Other items: sync-FIFO FT2232H bring-up (EEPROM to 245 FIFO mode plus a clocked FIFO in fabric), and a +/-1ppm TCXO or Si5351A for exact clocks (see Clock accuracy).
RTL complete and simulated (testbenches for the UART and FT245 paths, the mode meter, and PLL reconfig). Megafunctions generated and wired. The top elaborates clean. Pins assigned. Remaining: full Quartus compile and hardware bring-up, then the sync-FIFO upgrade for full-frame 60Hz headroom and, with external RAM, direct-colour modes (see Future development).





