A synthesizable Verilog-2001 video test pattern generator IP core. Outputs pixels over an AXI4-Stream master interface and is configured at runtime via an AXI4-Lite slave register interface. An optional adapter converts that stream to a classic parallel video interface (FVAL/LVAL/DVAL) for frame grabbers and camera-link style sinks.
- What this project does
- Features
- Register map
- How to use it
- Upgrading from 0.7
- How to test it
- FPGA resource usage and frequency
- File layout
- Author and license
vtpgZero produces video frames of various test patterns at programmable
resolution, pixel format, and bit depth. It can free-run from an internal
divider or be locked to an external frame-sync signal. The output stream is
standard AXI4-Stream with backpressure (tready), tlast = end of line, and
tuser = start of frame.
It ships in two flavors:
vtpgz_core— direct port-driven core. No AXI slave; drive thecfg_*fields from your own RTL (or tie them to constants for a fully static build). The smallest, most portable form.vtpgz_axilite_top— thin wrapper that adds an AXI4-Lite slave so a CPU/host can configure the core at runtime. This is what the Arty A7-100T demo uses.
Both have identical pattern/output behavior — they only differ in how the
cfg_* fields get set.
A third, optional module sits after either of them:
vtpgz_axis_to_flvdval— converts the AXI4-Stream output to parallel FVAL/LVAL/DVAL timing. It is a wire-level adapter with no FIFO, so it tiestreadyhigh and requires the source to free-run gap-free; that is exactly what vtpgZero does when nothing stalls it. See Parallel video out.
- 8 patterns: 8-bar color bars (100% or 75%), horizontal gradient, vertical gradient, checkerboard, solid color, crosshatch/grid, color ramp, LFSR pseudo-random noise.
- SMPTE RP 219 HD color bars (build-time
EN_SMPTE, off by default): the full chart, with 75% bars, -I/+Q, a Y ramp and PLUGE, at any resolution and pixels-per-clock. See SMPTE RP 219 color bars. - Bouncing box overlay: optional animated box drawn on top of any
pattern. Color, size, speed, and border are runtime-configurable; it
bounces off the frame edges. Stripped at elaboration when
EN_MOVING_BOX=0. - 3 build-time output modes (pick one at synthesis):
- RGB — RGB888 / RGB101010 / RGB121212
- RAW — single-component: plain monochrome, or any of the 4 standard Bayer mosaics (RGGB / BGGR / GRBG / GBRG). Smallest configuration, useful as an image-sensor emulator.
- YUV — native 4:4:4 or 4:2:2 (patterns produce
{Y,Cb,Cr}directly, no runtime color conversion). Selectable colorimetry: BT.601 or BT.709, full or limited (studio) range.
- 5 bit depths (build-time): 8 / 10 / 12 / 14 / 16 bits per component.
Patterns render at 12-bit precision; the pack stage truncates LSBs for
BPC<12and zero-extends forBPC>12. - Pixels-per-clock (1/2/4/8): pack 1, 2, 4, or 8 horizontally-adjacent
pixels into each AXI4-Stream beat for higher line bandwidth. All patterns
and output modes are supported;
PIXELS_PER_CLOCK=1(default) is byte-identical to prior releases. At 8 pixels per clock it runs 7680x4320 at 60 fps on a KV260, verified frame by frame on silicon. - Auto-derived
tdatawidth — the smallest multiple-of-8 that fits the active components for the chosen mode/bpc. No manual sizing needed. - AXI4-Lite slave for runtime configuration (19 writable registers, 22
with
EN_SMPTE=1). - AXI4-Stream master output with full backpressure support.
- Parallel video out (FVAL/LVAL/DVAL): optional
vtpgz_axis_to_flvdvaladapter presenting the classic frame-grabber timing interface, withFIELD_IDfor interlaced sources and configurable FVAL front/back porches. No FIFO — it requires a gap-free source and latches a stickyTIMING_ERRif that ever breaks, rather than emitting a corrupted raster. 9 LUT / 5 FF at zero porch. - Interlaced video (build-time
EN_INTERLACE): per-field SOF plus afidfield-ID sideband and afid_inlock input, following the AMD/Xilinx v_tpg (PG103) / UG934 convention. Stripped at elaboration whenEN_INTERLACE=0. - Frame sync: internal (clock divider) or external (rising-edge input).
- Build-time pattern selection: each pattern is gated by an
EN_*parameter and stripped from the netlist when set to0. - Pure Verilog-2001, no SystemVerilog, no vendor primitives.
- 100% Verilator code coverage (line + toggle) under the bundled testbench, plus a byte-exact RTL↔model gate and on-silicon hardware verification.
| Offset | Name | Description |
|---|---|---|
| 0x00 | CORE_ID | RO fixed magic 0x47505456 = ASCII "VTPG" little-endian. Read this first to confirm you're talking to a vtpgZero core. |
| 0x04 | VERSION | RO {major[8], minor[8], patch[16]} |
| 0x08 | CONTROL | [0] enable, [1] sw_fsync, [2] ext_sync, [3] interlace |
| 0x0C | STATUS | RO [0] busy, [1] field_id, [15:8] frame_count |
| 0x10 | IMG_WIDTH | active pixels per line |
| 0x14 | IMG_HEIGHT | active lines per frame (per field in interlaced mode — see Interlaced video) |
| 0x18 | PATTERN_SEL | 0=colorbar 1=hgrad 2=vgrad 3=checker 4=solid 5=smpte 6=grid 7=ramp 8=noise 9=image. 10..15 are unused and render black. |
| 0x1C | COLOR_FORMAT | RO build-time configuration mirror: [1:0]=output_mode (0=RGB 1=RAW 2=YUV), [2]=yuv_subsample (0=444 1=422), [5:3]=raw_bayer (0=PLAIN 1=RGGB 2=BGGR 3=GRBG 4=GBRG), [6]=rgb_order (0=Xilinx 1=legacy), [15:8]=BPC (8/10/12/14/16), [31:16]=TDATA_WIDTH |
| 0x20 | SOLID_COLOR | {8'h0, R[8], G[8], B[8]} |
| 0x24 | BOX_COLOR | moving box color |
| 0x28 | BOX_SIZE | {width[16], height[16]}. Each half is clamped by the core to 1 <= size <= IMG_WIDTH/IMG_HEIGHT, so a zero field renders a 1x1 box rather than disabling the overlay. Zero is not a supported configuration (it is also a divide-by-zero in the BOX_IMG_*_STEP host precompute); use BOX_COLOR equal to the background to make the overlay invisible. |
| 0x2C | BOX_SPEED | {dx[16], dy[16]} pixels per frame |
| 0x30 | PIXELS_PER_CLOCK | RO build-time pixels-per-AXI-beat (1/2/4/8) |
| 0x34 | GRID_SPACING | grid line spacing in pixels |
| 0x38 | GRID_COLOR | grid line color |
| 0x3C | CHECKER_SIZE | checkerboard square size in pixels |
| 0x40 | FRAME_RATE_DIV | clocks per frame for internal sync mode |
| 0x44 | BAR_WIDTH | colorbar width in pixels (host writes img_width/8) |
| 0x48 | HG_STEP | [27:0] horizontal-gradient (and RAMP) step per pixel, Q12.16 12-bit codes. Host writes ceil((0xFFF << 16) / (IMG_WIDTH - 1)). |
| 0x4C | VG_STEP | [27:0] vertical-gradient step per line, Q12.16. Host writes ceil((0xFFF << 16) / (IMG_HEIGHT - 1)). |
| 0x50 | BOX_BORDER | {border_width[8], border_color[24]}. Border is drawn inside the box; border_width=0 means no border. |
| 0x54 | BOX_IMG_X_STEP | Q16 nearest-neighbour step for the box-image overlay. Host writes (BOX_IMAGE_W << 16) / BOX_SIZE.width whenever BOX_SIZE changes. Only meaningful when EN_BOX_IMAGE=1. |
| 0x58 | BOX_IMG_Y_STEP | Q16 step for the box-image overlay y-axis. Host writes (BOX_IMAGE_H << 16) / BOX_SIZE.height. |
| 0x5C | STREAM_ROUTE | {tdest[15:0], tid[15:0]} driven onto m_axis_tdest / m_axis_tid, constant across every beat. Only meaningful when TID_WIDTH/TDEST_WIDTH > 0; otherwise the sidebands are absent and this register is inert. |
| 0x60 | SMPTE_GEOM0 | {bar_c[16], side_d[16]} SMPTE RP 219 bar width and side-panel width. Host writes c = (3*IMG_WIDTH + 14) / 28 (3W/28 rounded), d = (IMG_WIDTH - 7c) / 2. Only exists with EN_SMPTE=1 (reads 0 otherwise). See SMPTE RP 219 color bars. |
| 0x64 | SMPTE_GEOM1 | {pluge_p[16], row_h[16]} PLUGE step width and row unit. Host writes p = (c + 1) / 3, h = IMG_HEIGHT / 12. EN_SMPTE=1 only. |
| 0x68 | SMPTE_RAMP_STEP | [15:0] Y-ramp step, 1/256 of a 12-bit code per pixel. Host writes ceil((4095 << 8) / (5c - 1)). EN_SMPTE=1 only. |
AXI4-Lite writes honor WSTRB byte lanes. A write with WSTRB=0
acknowledges but leaves the addressed register unchanged, including
CONTROL. The COLOR_FORMAT register (0x1C) is read-only and reflects
the build-time configuration so software can probe it.
Everything is portable Verilog-2001 with no vendor primitives:
rtl/vtpgz_defs.vh `include this header (or add rtl/ to your include dirs)
rtl/vtpgz_core.v the substance: timing engine, patterns, inline pack
stage, AXIS output. Configured via cfg_* input ports.
rtl/vtpgz_axil_regs.v AXI4-Lite slave + register file (only needed with the
AXI-Lite wrapper)
rtl/vtpgz_axilite_top.v thin wrapper that adds the AXI4-Lite slave on top of
vtpgz_core for runtime control
A vtpgz_core-only build pulls in just vtpgz_defs.vh and vtpgz_core.v.
The AXI-Lite flavor adds the other two files.
| Parameter | Default | Effect |
|---|---|---|
C_S_AXI_ADDR_WIDTH |
8 | AXI4-Lite address width (8 = 256 B, enough for all regs) |
C_S_AXI_DATA_WIDTH |
32 | AXI4-Lite data width (only 32 supported) |
EN_COLORBAR … EN_NOISE |
1 | Per-pattern enables. Set to 0 to strip that generator from the netlist. At least one must remain enabled. |
EN_MOVING_BOX |
1 | Bouncing-box overlay. When 1, the box is drawn on top of any active pattern. |
EN_SMPTE |
0 | SMPTE RP 219 HD color bars as PATTERN_SEL=5. 0 (default) strips the pattern and its three geometry registers. See SMPTE RP 219 color bars. |
EN_IMAGE |
0 | Embed a synth-time image as PATTERN_SEL=9 (inferred BRAM). Stripped when 0. See Image patterns. |
EN_BOX_IMAGE |
0 | Paint an embedded image inside the moving box. Requires EN_MOVING_BOX=1. See Image patterns. |
IMAGE_W / IMAGE_H |
128 | Source image size in BRAM (powers of two). Plus IMAGE_OUT_W/H, IMAGE_HEX_FILE — see Image patterns. |
BOX_IMAGE_W / BOX_IMAGE_H |
32 | Box-image source size. Plus BOX_IMAGE_HEX_FILE. |
OUTPUT_MODE |
0 (RGB) | 0 = RGB; 1 = RAW; 2 = YUV. See Output modes. |
YUV_SUBSAMPLE |
0 (444) | Only for OUTPUT_MODE=2. 0 = 4:4:4; 1 = 4:2:2. |
YUV_RANGE |
0 (full) | Only for OUTPUT_MODE=2. 0 = full range (0..max); 1 = limited/studio range (Y 64..940, C 64..960 at 10 bits). See Output modes. |
YUV_MATRIX |
0 (BT.601) | Only for OUTPUT_MODE=2. 0 = BT.601 (Kr=0.299, Kb=0.114); 1 = BT.709 (Kr=0.2126, Kb=0.0722). Selects the colorbar palette. |
BAR_LEVEL |
100 | Colorbar level in percent, any output mode: 100 or 75 (in YUV, the SMPTE RP 219 bars). Selects the colorbar palette. |
RAW_BAYER |
1 (RGGB) | Only for OUTPUT_MODE=1. 0 = plain monochrome; 1 = RGGB; 2 = BGGR; 3 = GRBG; 4 = GBRG. |
RGB_ORDER |
0 (Xilinx) | Component order in tdata. 0 = {pad,B,G,R} (Xilinx PG044); 1 = {R,G,B,pad} legacy MSB-first. |
BPC |
8 | Bits per component: 8, 10, 12, 14, or 16. |
PIXELS_PER_CLOCK |
1 | Pixels per AXI-Stream beat (1/2/4/8). See Multi-pixel-per-clock. |
EN_INTERLACE |
0 | Add interlaced-video support (fid / fid_in, CONTROL[3]). 0 (default) strips every field register and ties fid / STATUS[1] to 0, so no interlace logic is generated (the ports remain, as constants). See Interlaced video. |
TID_WIDTH / TDEST_WIDTH |
0 | Add optional AXI4-Stream routing sidebands m_axis_tid / m_axis_tdest. 0 (default) strips them to a 1-bit tie-off, leaving the netlist unchanged. Set >0 and drive the value from the STREAM_ROUTE register (0x5C); it stays constant across every beat. |
PIX_TDATA_WIDTH / C_AXIS_TDATA_WIDTH |
(auto) | Derived — don't override. Per-pixel and full-beat tdata widths. |
vtpgz_core exposes the configuration fields as plain input ports — the
cfg_* ports mirror the writable AXI-Lite register fields (cfg_enable,
cfg_img_width, cfg_img_height, cfg_pattern, cfg_solid_color,
cfg_box_*, cfg_grid_*, cfg_checker_size, cfg_frame_rate_div,
cfg_bar_width, cfg_hg_step, cfg_vg_step, cfg_smpte_*, …). Status
outputs sts_busy
and sts_frame_count[7:0] are also exposed.
A typical fully-static instantiation — a 1920×1080 colorbar generator with no CPU in sight, free-running at 60 fps from a 130 MHz clock:
vtpgz_core #(
// Strip everything but colorbar to keep area minimal
.EN_COLORBAR (1),
.EN_HGRAD (0), .EN_VGRAD (0), .EN_CHECKER (0),
.EN_SOLID (0), .EN_MOVING_BOX(0), .EN_GRID (0),
.EN_RAMP (0), .EN_NOISE (0),
.OUTPUT_MODE (0), // 0=RGB 1=RAW 2=YUV
.RGB_ORDER (0), // 0=Xilinx 1=legacy
.BPC (8) // 8/10/12/14/16
) u_vtpgz (
.aclk (aclk),
.aresetn (aresetn),
// Static configuration -- tie everything to its compile-time value.
// The synth tool constant-folds disabled patterns and unused slots out.
.cfg_enable (1'b1), // run forever
.cfg_sw_fsync (1'b0),
.cfg_ext_sync (1'b0), // use internal frame sync
.cfg_interlace (1'b0), // progressive (EN_INTERLACE=1 only)
.cfg_img_width (16'd1920),
.cfg_img_height (16'd1080),
.cfg_pattern (4'd0), // colorbar
.cfg_solid_color (24'h000000),
.cfg_box_color (24'h000000),
.cfg_box_width (16'd0),
.cfg_box_height (16'd0),
.cfg_box_dx (16'd0),
.cfg_box_dy (16'd0),
.cfg_grid_spacing (16'd0),
.cfg_grid_color (24'h000000),
.cfg_checker_size (16'd0),
.cfg_frame_rate_div(32'd2_166_666), // 130 MHz / 60 fps
.cfg_bar_width (16'd240), // 1920 / 8
.cfg_hg_step (28'd0),
.cfg_vg_step (28'd0),
// Status -- leave dangling if you don't need them
.sts_busy (),
.sts_frame_count (),
.sts_field_id (),
// AXI4-Stream master (video out)
.m_axis_tdata (vid_tdata),
.m_axis_tvalid (vid_tvalid),
.m_axis_tready (vid_tready),
.m_axis_tlast (vid_tlast),
.m_axis_tuser (vid_tsof),
// Interlaced field ID (EN_INTERLACE=1 only; constant 0 otherwise)
.fid (vid_fid),
// External frame sync (only used when cfg_ext_sync=1)
.frame_sync_in (1'b0),
// Source field ID, sampled on the frame_sync_in edge (interlaced
// external-sync operation only)
.fid_in (1'b0)
);To drive cfg_* from your own RTL instead (an FSM that swaps patterns, a
sideband bus, etc.), just connect those signals to your logic instead of
constants — behavior is identical to a register write on the AXI-Lite flavor.
For the host-precomputed step values, use the same numbers as the
Programming sequence below.
The core is synchronous to a single clock aclk with synchronous
active-low reset aresetn, and emits video on a standard AXI4-Stream master
(tvalid / tready / tlast = end-of-line / tuser = start-of-frame). To
bring it up: hold aresetn low ≥1 cycle then release, drive cfg_* to
stable values, assert cfg_enable, pick a frame-sync source (see
External frame sync), and pull m_axis_tready high to
consume the stream.
Source: docs/wavedrom/tdata_stream.json5
(regenerate with python scripts/regen_wavedrom.py). Pxy = pixel at column
x, row y; PNL = last beat of the line (tlast=1). In the window above
P00..P30 are consumed cleanly, then the sink drops tready for two clocks
while the core holds tvalid=1 and the same P30 on tdata; the next beat
advances as soon as tready rises again.
Key behaviors:
- Latency from sync to first beat is 2 clocks.
tuser,tlast, andtdataare delayed together, so stream-visible frame contents are unchanged. - No blanking intervals — the core streams the active picture only.
Handle HBLANK/VBLANK downstream by holding
treadylow, or with a video-timing block. - Backpressure is honoured cycle-by-cycle. On
tvalid && !treadyall pipeline stages stall in place; nothing is dropped or repeated. - Mid-frame syncs are dropped, not queued — a
frame_startwhile a frame is in flight is ignored (correct for a free-running vsync). - Reset clears all state, so the first frame after
cfg_enablerises uses fresh pattern-counter state.
vtpgz_axilite_top instantiates vtpgz_axil_regs and a vtpgz_core
back-to-back. Its public interface is an AXI4-Lite slave for control plus the
same AXI4-Stream master for video. Wire your AXI4-Lite master and use the
Programming sequence to configure it.
vtpgz_axilite_top #(
.C_S_AXI_ADDR_WIDTH (8),
.C_S_AXI_DATA_WIDTH (32),
// Per-pattern EN_* default to 1; strip unused ones to save area
.OUTPUT_MODE (0), // 0=RGB 1=RAW 2=YUV
.YUV_SUBSAMPLE (0), // 0=444 1=422 (only for OUTPUT_MODE=2)
.RAW_BAYER (1), // 0=PLAIN 1=RGGB 2=BGGR 3=GRBG 4=GBRG (only for OUTPUT_MODE=1)
.RGB_ORDER (0), // 0=Xilinx 1=legacy
.BPC (8) // 8/10/12/14/16
// C_AXIS_TDATA_WIDTH is auto-derived; don't override
) u_vtpgz (
.aclk (aclk), .aresetn (aresetn),
// AXI4-Lite slave (control) — standard channels: aw/w/b/ar/r
.s_axi_awaddr (axi_awaddr), .s_axi_awprot (axi_awprot),
.s_axi_awvalid(axi_awvalid),.s_axi_awready(axi_awready),
.s_axi_wdata (axi_wdata), .s_axi_wstrb (axi_wstrb),
.s_axi_wvalid (axi_wvalid), .s_axi_wready(axi_wready),
.s_axi_bresp (axi_bresp), .s_axi_bvalid(axi_bvalid),
.s_axi_bready (axi_bready),
.s_axi_araddr (axi_araddr), .s_axi_arprot(axi_arprot),
.s_axi_arvalid(axi_arvalid),.s_axi_arready(axi_arready),
.s_axi_rdata (axi_rdata), .s_axi_rresp (axi_rresp),
.s_axi_rvalid (axi_rvalid), .s_axi_rready(axi_rready),
// AXI4-Stream master (video). Width auto-derived from OUTPUT_MODE/BPC;
// read it at runtime from COLOR_FORMAT[31:16] if needed.
.m_axis_tdata (vid_tdata), .m_axis_tvalid(vid_tvalid),
.m_axis_tready(vid_tready), .m_axis_tlast (vid_tlast), // end-of-line
.m_axis_tuser (vid_tsof), // start-of-frame (1 bit)
// External frame sync (used when CONTROL[2]=1)
.frame_sync_in (ext_fsync)
);The core has one clock domain (aclk) — AXI, pattern logic, and video
output are all clocked by it. If your video sink runs on a different clock,
put an asynchronous AXI4-Stream FIFO on the output. m_axis_tuser is 1 bit
(asserts on the first beat of each frame), matching the Xilinx convention.
-
Hold
aresetnlow for at least oneaclkcycle and release. -
Write
IMG_WIDTH,IMG_HEIGHTto the desired resolution. -
Write the host-precomputed step values (these replace per-pixel dividers with cheap accumulators in hardware):
BAR_WIDTH = IMG_WIDTH / 8(colorbar bar width)HG_STEP = ceil((0xFFF << 16) / (IMG_WIDTH - 1))(horizontal gradient / ramp step)VG_STEP = ceil((0xFFF << 16) / (IMG_HEIGHT - 1))(vertical gradient step)
The gradient steps are Q12.16 (12-bit codes per pixel or line, 16 fraction bits), so a gradient spans the full 0..0xFFF range at any width up to 65535, 8K included, with every pixel within one code of the ideal ramp. Rounding up makes the last pixel land on 0xFFF; the core saturates the overshoot. Any larger step, up to the full 28 bits, also saturates: once a line (or frame) reaches 0xFFF it stays there and never wraps. In C:
((0xFFFu << 16) + n - 2) / (n - 1)forn > 1. This encoding changed in 0.8.0; host code written for 0.7 or earlier must be updated, see Upgrading from 0.7.- with
EN_SMPTE=1, the threeSMPTE_*geometry registers (formulas in the register map). Their reset values are the 1920x1080 geometry.
-
Write
PATTERN_SEL(0=colorbar, 1..4=gradients/checker/solid, 5=smpte, 6=grid, 7=ramp, 8=noise, 9=image). The bouncing box overlays whatever pattern is selected. -
Optional pattern parameters:
SOLID_COLOR,BOX_COLOR/BOX_SIZE/BOX_SPEED/BOX_BORDER,GRID_SPACING/GRID_COLOR,CHECKER_SIZE. -
For internal sync mode (
CONTROL[2]=0): writeFRAME_RATE_DIV(one frame every Naclks). -
Write
CONTROL = 0x1(enable). The core starts streaming.
To change configuration, write CONTROL = 0 first, wait for the in-flight
frame to drain, then reprogram and re-enable. (COLOR_FORMAT is read-only —
it mirrors the build-time configuration; you never write it.)
Drive frame_sync_in with any pulse-style signal that marks the start of a
frame and set CONTROL[2] = 1 (ext_sync mode). The core does
rising-edge detection internally, so it accepts 1-clock pulses,
multi-clock pulses (including signals held high — only the edge counts), and
free-running periodic vsync at any rate slower than the frame emission time.
This is directly compatible with Xilinx VTC's vsync_out.
A rising edge that arrives while a frame is still in flight is dropped
(not queued) — correct for a free-running vsync. For latched-pending
behavior, add your own pending-bit FF before frame_sync_in.
Build with EN_INTERLACE = 1 and set CONTROL[3] to emit interlaced fields.
The implementation follows the AMD/Xilinx AXI4-Stream video convention used
by the Video Test Pattern Generator (PG103) and UG934, so the stream drops
straight into a Xilinx video pipeline:
IMG_HEIGHTholds the FIELD height — programframe_height / 2(540 for 1080i), exactly as PG103 requires foractive_height. A field is what the timing engine already calls a frame, so the pixel datapath, the patterns and the reference model are untouched by this feature.m_axis_tuser(SOF) asserts on the first beat of every field;m_axis_tlaststays end-of-line.fid(named as on v_tpg, so Vivado does not fold it into them_axisinterface) carries the field ID:0= even (top) field,1= odd (bottom) field. It is sampled coincident with SOF and held stable for every beat of the field. It is constant0for a progressive stream (CONTROL[3] = 0, or anEN_INTERLACE = 0build), as UG934 requires.- One frame-sync pulse starts one field.
FRAME_RATE_DIVtherefore sets the field rate, and an externalframe_sync_inis expected at twice the frame rate (59.94 Hz for 1080i29.97).STATUS[15:8]counts fields. - Internal sync alternates
fiditself (0, 1, 0, 1, …). External sync samples thefid_inport on the sync edge instead, so the core stays locked to the field parity of the upstream source; repeat a field by repeating thefid_invalue. The port carries the same field information v_tpg'sfid_indoes, but the sync-edge sampling is vtpgZero's own design — PG103 describes itsfid_inas a pass-through input, not a generator parity input. Switching external → internal between fields continues the alternation from the last emitted field rather than repeating it. - A fresh enable restarts at the even field: dropping
CONTROL[0](orCONTROL[3]) across an idle cycle clears the parity, so the next field is field 0. A field already in flight always keeps its ownfidto the last beat — clearing CONTROL mid-field does not retag the tail. STATUS[1]reads back the field the timing engine is currently producing (source side, so it runs slightly ahead offid).
Because each field is rendered in field coordinates, a feature N lines tall
spans roughly 2N scanlines once the two fields are woven — v_tpg documents
the same effect for its box ("NxN for progressive video and Nx2N for
interlaced"). So program BOX_SIZE height for half what you want on the
woven frame: an N-tall box on screen means programming N/2.
VG_STEP is a step, not a height, so it follows the usual formula against
the programmed (field) height — VG_STEP = ceil((0xFFF << 16) / (field_height - 1)),
i.e. roughly twice the progressive step for the same frame. Each field then
sweeps the full gradient on its own, as v_tpg's field-coordinate rendering
does.
Two integration notes for real AMD pipelines:
fidis deliberately not part of the inferredm_axisinterface, so a block design that connectsm_axisdoes not carry it along — wirefidto the consumer's field input yourself (and configure that core for interlace). AXI4-Stream to Video Out samples it with SOF, as UG934 states.- Polarity matches v_tpg (
0= even,1= odd), but the Video Processing Subsystem deinterlacer'sdeint_field_iduses the opposite polarity (PG231) — invertfidwhen driving that core.
frame_sync_in and fid_in are sampled directly by aclk with no
synchronizer: both must be synchronous to aclk, and fid_in must be stable
around the sync edge that starts the field. Cross a clock domain yourself
before these pins.
rtl/vtpgz_axis_to_flvdval.v converts the AXI4-Stream output into the classic
parallel video timing interface used by frame grabbers and camera links:
| Signal | Meaning |
|---|---|
FVAL |
frame valid — high for the whole frame (the whole field when interlaced) |
LVAL |
line valid — high for the whole active line |
DVAL |
data valid — qualifies one beat of PIX_DATA |
FIELD_ID |
fid of the frame FVAL encloses, latched at its SOF |
vtpgz_axis_to_flvdval #(
.TDATA_WIDTH (24), // one beat; = one pixel at PIXELS_PER_CLOCK=1
.FVAL_LEAD (0), // FVAL front porch, in cycles
.FVAL_TRAIL (0) // FVAL back porch, in cycles
) u_flv (
.aclk(aclk), .aresetn(aresetn),
.s_axis_tdata(m_axis_tdata), .s_axis_tvalid(m_axis_tvalid),
.s_axis_tready(m_axis_tready), .s_axis_tlast(m_axis_tlast),
.s_axis_tuser(m_axis_tuser), .s_axis_eof(eof), .s_axis_fid(fid),
.pix_data(pix_data), .fval(fval), .lval(lval), .dval(dval),
.field_id(field_id),
.timing_err(timing_err), .timing_err_clr(1'b0)
);There is no FIFO, and there cannot be a stall. FVAL/LVAL/DVAL has no
backpressure, so the adapter ties TREADY high permanently and relies on the
source running gap-free. vtpgZero does: with TREADY high it emits exactly
IMG_WIDTH/PPC contiguous beats per line, then LINE_GAP_CYCLES of idle,
with no bubbles inside a line. Put this behind anything that can stall (a
crossbar, a DMA, a slower-draining CDC FIFO) and that breaks — not silently:
LVAL stays high across a mid-line bubble while DVAL drops, and
TIMING_ERR latches. Such a source needs a line or frame FIFO in front.
The adapter also needs eof, the core's end-of-frame marker (asserted on the
same beat as the final m_axis_tlast of the frame). AXIS video leaves EOF
implicit; the core already computes it, so it is brought out as a port.
The adapter has no buffer, so it can only reproduce the gaps the source already leaves. Both blanking intervals are therefore set upstream:
| Interval | Set by | Cycles |
|---|---|---|
| Horizontal blanking (line break) | LINE_GAP_CYCLES (build parameter, min 1) |
LINE_GAP_CYCLES, after every line but the last |
| Vertical blanking (frame break) | FRAME_RATE_DIV (runtime register) |
FRAME_RATE_DIV - ACTIVE |
where the active time of one frame (field) is
ACTIVE = (IMG_WIDTH / PPC) * IMG_HEIGHT + LINE_GAP_CYCLES * (IMG_HEIGHT - 1)
So to hit a required blanking spec, pick LINE_GAP_CYCLES for the line break
and then FRAME_RATE_DIV = ACTIVE + wanted vertical blanking.
FRAME_RATE_DIV must be strictly greater than ACTIVE. Frames do not
overrun each other if it is not — the timing engine only accepts a sync tick
while it is idle, so ticks landing inside an active frame are simply dropped
(including one coincident with the final active cycle). The frame period
becomes
PERIOD = FRAME_RATE_DIV * (floor(ACTIVE / FRAME_RATE_DIV) + 1)
VBLANK = PERIOD - ACTIVE
so the frame rate silently drops to a submultiple rather than the blanking
going negative. Measured on the RTL with ACTIVE = 131: FRAME_RATE_DIV of
132 gives 1 cycle of blanking, 133 gives 2, 400 gives 269 — but 131 gives a
262-cycle period with 131 cycles of blanking (half the requested rate), and
100 gives a 200-cycle period with 69. Under external frame sync,
FRAME_RATE_DIV does not determine vertical blanking at all; the sync source
does.
FVAL_LEAD and FVAL_TRAIL carve the FVAL porches out of that vertical
blanking, so they are only realisable while
FVAL_LEAD + FVAL_TRAIL < FRAME_RATE_DIV - ACTIVE. The front porch is
implemented by delaying the pixel path, so it costs FVAL_LEAD cycles of
latency and FVAL_LEAD * (TDATA_WIDTH+3) registers — which Vivado largely
maps to SRLs, so the real cost is lower than that count suggests. The back
porch is just a counter. At FVAL_LEAD = 0 no delay line is generated and
FVAL rises together with the first LVAL.
Out-of-context synthesis on xc7a100tcsg324-1 at TDATA_WIDTH=24:
FVAL_LEAD=0, FVAL_TRAIL=0 costs 9 LUT / 5 FF; FVAL_LEAD=4, FVAL_TRAIL=6
costs 41 LUT / 65 FF.
If the porches do overrun the vertical blanking, the adapter does not fail
silently: a new frame's SOF arriving while the previous frame is still
draining the delay line latches TIMING_ERR, which also covers mid-line
bubbles. Consecutive frames genuinely overlap inside the delay line there, so
no correct FVAL exists to emit — the condition is reported rather than
papered over.
A classic parallel interface is one pixel per clock, so use
PIXELS_PER_CLOCK=1. At PPC=N the adapter passes TDATA_WIDTH straight
through and presents N pixels per DVAL on a wide bus — fine for an on-chip
sink, but a true parallel link needs an external N:1 serializer running N
times faster.
At PIXELS_PER_CLOCK of 2/4/8, that many horizontally-adjacent pixels are
packed into one wider beat (lane 0 = leftmost pixel in the tdata LSBs),
multiplying line bandwidth. Every pattern and the moving-box overlay
(fill, border, box-image) produce beat-exact output, across all output modes
(RGB / RAW-Bayer / YUV 4:4:4 / YUV 4:2:2) and all bit depths. IMG_WIDTH is
clamped down to a multiple of PIXELS_PER_CLOCK. Only an illegal value
(not 1/2/4/8) fails elaboration; the active value is mirrored read-only at
register 0x30 so software can discover it.
The PIXELS_PER_CLOCK=1 build is byte-identical to prior releases (the
per-lane logic is generate-gated behind PPC>1). NOISE uses a leap-ahead
LFSR so each lane gets consecutive states. IMAGE / BOX_IMAGE replicate their
source memory per lane — see Image patterns.
-
RGB (
OUTPUT_MODE=0) — 3-component RGB packed as{pad,B,G,R}(or{R,G,B,pad}withRGB_ORDER=1). -
RAW (
OUTPUT_MODE=1) — 1 component per pixel.RAW_BAYER=0is plain monochrome (G channel);1..4are the RGGB / BGGR / GRBG / GBRG Bayer mosaics (standard row-major naming). The smallest configuration (~50 LUT smaller than RGB), useful as an image-sensor emulator. -
YUV (
OUTPUT_MODE=2) — YCbCr emitted directly from the pattern generators, no runtime color-space conversion. The colorbar uses a precomputed palette; grayscale-style patterns put their value in Y and hold Cb=Cr=0x800 (neutral chroma). Color registers are interpreted as{Y,Cb,Cr}triples in YUV builds.YUV_SUBSAMPLE=0→ 4:4:4 ({V,U,Y});=1→ 4:2:2 ({C,Y}, C alternates Cb on even-x and Cr on odd-x).YUV_MATRIXpicks the colorbar colorimetry — BT.601 (SD) or BT.709 (HD) — andYUV_RANGEpicks full or limited (studio) range.BAR_LEVEL=75gives 75% bars in any mode (in YUV BT.709 limited at 10 bpc, exactly SMPTE RP 219). Every combination is a build-time constant palette, and none of them costs logic, since only one is ever elaborated.Each palette holds the exact code for the build's bit depth. An 8-bit limited code is not the 10-bit one truncated (BT.601 cyan Y is 170 at 8 bits, but 678 >> 2 = 169), so 8-, 10- and 12-bit builds each get their own constants, and all of them equal the published tables. BPC 14/16 use the 12-bit codes. Full range follows H.273 / BT.2100 at every depth. The palettes are generated from the colorimetry by
scripts/gen_yuv_palettes.py, which writes them intovtpgz_core.v; CI fails if the two drift apart. The one exception is the shipped default, BT.601 full range 100%, which is kept bit-for-bit as it always was (two chroma values sit one LSB off the formula).In a limited-range build the patterns whose luma is a runtime value (gradients, checker, ramp, noise) are compressed onto the legal range by
Y_lim = 256 + ((Y * 3505) >> 12) 0 -> 256, 4095 -> 3760which lands exactly on 64..940 at 10 bits and 16..235 at 8. The scale is implemented as shifts and adds, not as a multiply: Vivado infers a DSP48 for the multiply form even with a constant operand, and no mode in this core is meant to cost a DSP. Verified by synthesis, not assumed.
Color registers are not rescaled, in either range.
SOLID_COLOR,GRID_COLORand the box colors are raw code values that pass through untouched, so a host that writes an exact code reads that exact code back. In a limited-range build it is the host's job to write legal values — the core will happily emit0x3FFif you ask it to. The grid background does follow the range, because its luma is a build-time constant.IMAGE in YUV builds is converted at build time, not in the core. Generate the image memory with
scripts/image_to_hex.py --yuv --matrix {601,709} --range {full,limited}, matching the build'sYUV_MATRIXandYUV_RANGE, and the core passes those codes through untouched (widened by a shift, so 8-bit 128 is exactly 10-bit 512). A YUV build that enables an image but still points at the shipped RGB mandrill files fails at elaboration; any other RGB memory comes out as the wrong colours, and the core cannot tell, so keep the two in step. The padding around a centred image is the build's black. See docs/images.md.
A pattern stripped at build time still has its PATTERN_SEL slot, but
selecting it at runtime produces a black frame. At least one pattern must
remain enabled.
EN_SMPTE=1 adds the SMPTE RP 219 HD color-bar chart as PATTERN_SEL=5. It
is off by default. A build without it has no chart logic and no SMPTE_*
registers (they read 0), and slot 5 renders black.
The chart has four row bands, 7/12, 1/12, 1/12 and 3/12 of the height:
| Row | Left to right |
|---|---|
| 1 | 40% grey, 75% white, yellow, cyan, green, magenta, red, blue, 40% grey |
| 2 | 100% cyan, -I, 75% white, 100% blue |
| 3 | 100% yellow, +Q, Y ramp black to white, 100% white, 100% red |
| 4 | 15% grey, black, 100% white, black, PLUGE -2% / black / +2% / black / +4%, black, 15% grey |
RP 219 allows a choice of color in the second patch of rows 2 and 3; this
chart uses -I and +Q. The bars are always 75%: BAR_LEVEL applies to the
8-bar pattern only.
- Colors. The palette is generated for the build's output mode, matrix,
range and bit depth, like the 8-bar palettes. In YUV BT.709 limited range
it is RP 219's published table exactly. The 10-bit codes of every color
and the 12-bit -I/+Q anchors are checked against literal values by
hw/arty_a7_100t/python/check_smpte_spec.py. The -2% PLUGE step is below black, so it only shows in limited-range YUV (46 at 10 bits). In RGB and full-range YUV there is nothing below black and it clips to black. - Geometry. The host writes the bar width
c, side paneld, PLUGE steppand row unith(formulas in the register map). The core derives the row-4 widths 3c/2 and 2c, and makes 5c/6 the remainder, so both column layouts end atd + 7c(unless a width is too small and gets clamped, see below). The reset values are the 1920x1080 geometry: c=206, d=239, p=69, h=90. Every bar iscwide, so at 1920 the chart is not pixel-identical to RP 219's table, which alternates 205- and 206-pixel bars around 240-pixel side panels. The structure and colors match. Every width is clamped to at leastPIXELS_PER_CLOCK. Like the other geometry, these registers are meant to be written while the core is disabled: a write during a frame takes effect at the next line or frame, not at a clean frame boundary. - Ramp. The Y ramp spans the five middle bars. Its step is rounded up
and the end clipped, so it reaches white exactly on its last pixel: 64 to
940 at 10 bits in limited range, 0 to full scale otherwise. This needs
c >= 4(a frame at least 33 pixels wide); below that the step does not fit its 16 bits. - Interlaced. Like every pattern, the chart is drawn per field. With
IMG_HEIGHTset to the field height,h = IMG_HEIGHT / 12gives a full chart in each field, and the woven frame has the same proportions. - Cost. About +660 LUT / +650 FF at 1 pixel per clock and +920 LUT /
+830 FF at 4, with no DSP. The Arty demo with the chart still closes
100 MHz at 4 pixels per clock. The ramp's start offset
(d + c) * stepcomes from a serial shift-add multiplier that settles within ~40 clocks of a register write. Full numbers are in docs/resources.md.
EN_IMAGE bakes a 24-bit RGB888 image into inferred BRAM and draws it as
PATTERN_SEL=9, centred with black padding and hardware nearest-neighbour
scaling. EN_BOX_IMAGE paints a second embedded image inside the moving box.
Both are stripped (no BRAM cost) when disabled. Full details — parameters,
scaler internals, BRAM sizing, and the PNG/JPG → .mem conversion script —
are in docs/images.md.
0.8.0 breaks register compatibility in one place: the gradient step registers. Everything else (addresses, other fields, the ports other than the two step inputs, stream format) is unchanged.
HG_STEP (0x48) and VG_STEP (0x4C) are now Q12.16. Up to 0.7 they
held a whole number of 12-bit codes per pixel (or line) in bits [15:0].
Any width above 4096 gave a step of 0, so gradients and RAMP could not
sweep at 8K. They now hold codes per step with 16 fraction bits in
[27:0]:
| 0.7 and earlier | 0.8.0 | |
|---|---|---|
| Format | integer codes, [15:0] |
Q12.16 codes, [27:0] |
HG_STEP |
0xFFF / (IMG_WIDTH - 1) |
ceil((0xFFF << 16) / (IMG_WIDTH - 1)) |
VG_STEP |
0xFFF / (IMG_HEIGHT - 1) |
ceil((0xFFF << 16) / (IMG_HEIGHT - 1)) |
| 1920x1080 values | 2, 3 |
139849, 248721 |
| Reset value | 546, 970 (saturated after a few pixels) |
139849, 248721 (1920x1080) |
| Widest full sweep | 4096 | 65535 |
RAMP (PATTERN_SEL=7) reuses HG_STEP, so it changes too.
SMPTE_RAMP_STEP keeps its old format.
Symptom of an un-updated host. No error is raised. The old value is
read as 1/65536 of a code per step, so HGRAD, VGRAD and RAMP come
out black (or almost black) across the whole frame. The other patterns
are unaffected.
Detecting the version. VERSION (0x04) reads
{major[8], minor[8], patch[16]}: 0.8.0 is 0x00080000. Host code that
has to drive both generations can choose the formula from it:
uint32_t ver = rd32(VTPGZ_BASE + 0x04);
int q1216 = ver >= 0x00080000u; /* 0.8.0 and later */
uint32_t hg = q1216 ? ((0xFFFu << 16) + w - 2u) / (w - 1u)
: 0xFFFu / (w - 1u);
uint32_t vg = q1216 ? ((0xFFFu << 16) + h - 2u) / (h - 1u)
: 0xFFFu / (h - 1u);
wr32(VTPGZ_BASE + 0x48, hg);
wr32(VTPGZ_BASE + 0x4C, vg);Direct vtpgz_core users. The cfg_hg_step and cfg_vg_step ports
widened from [15:0] to [27:0]. A parent that still drives them from a
16-bit signal compiles without error, because Verilog zero-extends the
value. It then shows the black-gradient symptom above. Widen the driving
signal and switch to the new formula.
Behaviour fix: no stale first beat. The per-line pattern state
(VGRAD's accumulator and the checker and grid row counters) now resets
on the last beat of a frame instead of at pix_sof. Before 0.8.0, the
first beat of every frame after the first one carried the previous
frame's last-row value, and a frame one beat wide (IMG_WIDTH == PIXELS_PER_CLOCK) was wrong on every line. Output that matched 0.7 on
frame 1 still matches. Frames 2 onward now match frame 1, where before
they differed in that first beat.
All simulation is driven by one Python script (no Makefile):
python sim/run_sim.py regression # lint + build + run + 100% coverage + model gate
python sim/run_sim.py all_modes # byte-exact sim↔model gate across every mode/BPC
python sim/cocotb/run_ppc.py # beat-exact pixels-per-clock data path (1/2/4/8)
python sim/run_iverilog_interlace.py # interlaced field ID (fid) semantics
python sim/run_iverilog_flvdval.py # FVAL/LVAL/DVAL adapter + gap-free source
python sim/run_iverilog_black.py # empty/stripped pattern slots are real black in YUV
python sim/check_image_guard.py # YUV builds refuse the RGB default images
python sim/check_ppc_vs_model.py # beat-exact RTL vs model at PPC 1/2/4/8 (Icarus), SMPTE included
python hw/arty_a7_100t/python/check_yuv_range.py # YUV colorimetry vs the standards
python hw/arty_a7_100t/python/check_smpte_spec.py # SMPTE RP 219 codes, layout and ramp
python hw/arty_a7_100t/python/check_yuv_range_mutations.py # ...and proof those checks biteThere is also an Icarus smoke test, a cocotb control-plane suite, and a hardware regression on the Arty A7-100T (all 9 patterns byte-exact vs the Python model, verified on silicon in the default RGB build and in a YUV limited-range BT.709 build). Setup, expected output, the 7-phase coverage harness, and the hardware flow are documented in docs/testing.md.
Measured on the full Arty A7-100T demo (Vivado 2025.2, default strategies):
| Metric | Value |
|---|---|
| Target | Digilent Arty A7-100T (XC7A100TCSG324-1, speed grade -1) |
| Clock | 100 MHz at 4 pixels/clock = 400 Mpixel/s (on-board 100 MHz osc via MMCM) |
| WNS | +0.890 ns at 100 MHz (timing met, 0 failing endpoints) |
| LUTs | 3589 / 63400 = 5.66% |
| FFs | 3541 / 126800 = 2.79% |
| BRAM36 | 8 / 135 = 5.93% |
| Hardware test | 9/9 patterns byte-exact vs Python model |
That row includes the whole demo (core + frame_capture + fpgacapZero
JTAG-AXI bridge + BRAM frame buffer + MMCM), and now also the
vtpgz_axis_to_flvdval adapter and the flv_monitor raster instrument that
the on-silicon parallel-video test reads — neither of which you pay for
unless you instantiate them. Standalone, the all-patterns
vtpgz_axilite_top is ~1400 LUT / 1268 FF (RGB-8b, OOC synth, no BRAM,
no DSP), and ~2450 LUT at 4 pixels per clock. Pixels-per-clock scales
sub-linearly: 8× throughput costs about 2.8× the LUTs. The smallest build
(1 pattern, RAW-8b) is ~546 LUT.
8Kp60 on silicon. On a KV260 (Zynq UltraScale+, -2LV), the core runs at 8 pixels per clock and 250 MHz and streams 7680x4320 at 60 fps (active video; with CTA-861 blanking the 8Kp60 raster needs 297 MHz). Every frame has the full 4,147,200 beats with no mid-line bubble and an exact 1/60 s period, and its CRC-32 matches the bit-exact model's for all nine patterns. Free-running, it reaches 60.2 fps. Out of context, a ZU7EV -2 closes the core at ~284 MHz. The bench and its results are in hw/kv260/README.md, and the fmax numbers in docs/resources.md.
The full per-configuration matrix — every mode/BPC, per-feature deltas, the
PPC sweep, interlace, the YUV colorimetry options, the SMPTE RP 219 chart,
and the tiniest build — is
in docs/resources.md.
Reproduce with python synth/run_matrix.py.
rtl/
vtpgz_defs.vh constants, register addresses, pattern/format codes
vtpgz_axil_regs.v AXI4-Lite slave + register file
vtpgz_core.v port-driven core: timing engine, pattern generators,
inline pack stage, AXIS output
vtpgz_axilite_top.v thin wrapper: vtpgz_axil_regs + vtpgz_core
vtpgz_axis_to_flvdval.v optional adapter: AXIS video -> parallel
FVAL/LVAL/DVAL (no FIFO, source must be gap-free)
tb/ Icarus Verilog smoke testbench
sim/ Verilator/iverilog/cocotb harnesses + run_sim.py
(see docs/testing.md)
synth/ Vivado OOC resource-matrix driver (run_matrix.py)
docs/
testing.md simulation + hardware regression detail
images.md EN_IMAGE / EN_BOX_IMAGE detail
resources.md full FPGA resource matrix
img/, wavedrom/ figures and their sources
fcapz/ git submodule: upstream fpgacapZero RTL + host tools
hw/
arty_a7_100t/ Arty A7-100T reference design + Python host
kv260/ KV260 DisplayPort reference design
LICENSE Apache-2.0
- Author: Leonardo Capossio — bard0 design — hello@bard0.com — bard0
- License: Apache License 2.0 — see LICENSE
