From c88074896be39c39786674a42c230ef66c0171b1 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Wed, 29 Jul 2026 00:11:39 +0800 Subject: [PATCH 01/13] Add PIXELS_PER_CLOCK build param (M1: solid/grid/checker/box) Build-time PIXELS_PER_CLOCK (1/2/4/8) packs N adjacent pixels per AXI-Stream beat, lane 0 in the tdata LSBs. M1 scope: SOLID, GRID, CHECKER and the box overlay are beat-exact at PPC>1; stateful patterns still require PPC=1 and fail elaboration if enabled. PPC=1 is unchanged. Core: x advances by NPPC, width clamped to a multiple; per-lane grid/checker chains, box comparators, widened pipeline, reusable pack_pixel(); PPC mirrored RO at reg 0x30. Verify: model render_frame_beats() plus iverilog harness beat-exact across 36 configs. Verilator coverage gate still to be re-baselined. --- README.md | 19 +- hw/arty_a7_100t/python/vtpgz_model.py | 45 +++ rtl/vtpgz_axil_regs.v | 2 + rtl/vtpgz_axilite_top.v | 11 +- rtl/vtpgz_core.v | 457 +++++++++++++++++--------- rtl/vtpgz_defs.vh | 12 +- sim/check_ppc_vs_model.py | 185 +++++++++++ sim/tb_ppc_capture.v | 197 +++++++++++ 8 files changed, 772 insertions(+), 156 deletions(-) create mode 100644 sim/check_ppc_vs_model.py create mode 100644 sim/tb_ppc_capture.v diff --git a/README.md b/README.md index 1b8345b..e010c7c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ standard AXI4-Stream with backpressure (`tready`), `tlast` = end of line, and | 0x24 | BOX_COLOR | moving box color | | 0x28 | BOX_SIZE | `{width[16], height[16]}` | | 0x2C | BOX_SPEED | `{dx[16], dy[16]}` pixels per frame | -| 0x30 | *(reserved)* | future use | +| 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 | @@ -283,7 +283,22 @@ rtl/vtpgz_axilite_top.v — thin wrapper that adds an AXI4-Lite slave on | `RAW_BAYER` | 1 (RGGB)| Only meaningful when `OUTPUT_MODE=1`. **0** = plain monochrome (G channel); **1** = RGGB; **2** = BGGR; **3** = GRBG; **4** = GBRG. The four Bayer tiles follow standard naming (row-by-row, left to right, top to bottom) | | `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. Allowed: 8, 10, 12, 14, 16. Patterns render at 12-bit; pack stage truncates LSBs (`BPC<12`), passes through (`BPC=12`), or zero-extends LSBs (`BPC>12`) | -| `C_AXIS_TDATA_WIDTH` | (auto) | **Derived**: smallest multiple-of-8 that holds the active components. Don't override unless you really know what you're doing | +| `PIXELS_PER_CLOCK` | 1 | Pixels emitted per AXI-Stream beat. **1** (default) = classic one-pixel-per-beat, netlist identical to prior releases. **2 / 4 / 8** pack that many horizontally-adjacent pixels into one wider beat (lane 0 = leftmost pixel in the `tdata` LSBs), multiplying line bandwidth. `IMG_WIDTH` is clamped **down** to a multiple of `PIXELS_PER_CLOCK`. See the note below for the pattern-support caveat. | +| `PIX_TDATA_WIDTH` | (auto) | **Derived**: per-*pixel* packed width — smallest multiple-of-8 that holds the active components. Don't override. | +| `C_AXIS_TDATA_WIDTH` | (auto) | **Derived**: full beat width = `PIXELS_PER_CLOCK × PIX_TDATA_WIDTH`. Don't override unless you really know what you're doing | + +**Multi-pixel-per-clock (`PIXELS_PER_CLOCK` > 1) — current support (M1)**: +At `PIXELS_PER_CLOCK` of 2/4/8 the build is restricted to the +position-combinational patterns — **SOLID, GRID, CHECKER**, plus the +**moving-box overlay** (fill + border). These produce byte-exact output at +any PPC. The accumulator / counter / stateful patterns (**COLORBAR, HGRAD, +VGRAD, RAMP, NOISE, IMAGE, BOX_IMAGE**) still require `PIXELS_PER_CLOCK=1`; +enabling any of their `EN_*` in a PPC>1 build **fails elaboration** with a +named error module rather than emitting wrong pixels. All output modes +(RGB / RAW-Bayer / YUV 4:4:4 / YUV 4:2:2) and all bit depths are supported +at every PPC. The `PIXELS_PER_CLOCK` value is mirrored read-only at register +offset `0x30` so software can discover it. Widening the remaining patterns +to PPC>1 is planned follow-on work (M2/M3). **Output mode notes**: - `OUTPUT_MODE=0` (RGB) outputs 3-component RGB packed as diff --git a/hw/arty_a7_100t/python/vtpgz_model.py b/hw/arty_a7_100t/python/vtpgz_model.py index 9211d04..95d8e8c 100644 --- a/hw/arty_a7_100t/python/vtpgz_model.py +++ b/hw/arty_a7_100t/python/vtpgz_model.py @@ -112,11 +112,22 @@ class VtpgzConfig: box_img_x_step: int = 0 box_img_y_step: int = 0 box_image_rgb888: list = field(default_factory=list) + # Pixels emitted per AXI-Stream beat (build-time). 1 = classic + # one-pixel-per-beat. 2/4/8 pack that many horizontally-adjacent pixels + # into one wider beat, lane 0 (leftmost pixel) in the LSBs. width must + # be a multiple of pixels_per_clock. See render_frame_beats(). + pixels_per_clock: int = 1 @property def tdata_width(self) -> int: + """Per-PIXEL packed width (unchanged by PPC).""" return derived_tdata_width(self.output_mode, self.bpc, self.yuv_subsample) + @property + def beat_tdata_width(self) -> int: + """Full AXI-Stream beat width = pixels_per_clock * per-pixel width.""" + return self.pixels_per_clock * self.tdata_width + @dataclass class VtpgzRegs: @@ -560,6 +571,40 @@ def render_frame(cfg: VtpgzConfig, regs: VtpgzRegs | None = None) -> list[int]: return out +def render_frame_beats(cfg: VtpgzConfig, + regs: VtpgzRegs | None = None) -> list[int]: + """Render one frame as multi-pixel-per-clock AXI-Stream beats. + + Semantics are defined entirely in terms of the per-pixel model: a + pixels_per_clock=N build emits the EXACT same per-pixel value sequence + as a PPC=1 build, just N pixels at a time. Beat b of a line therefore + carries pixels x = b*N .. b*N+N-1 (lane 0 = leftmost = LSBs): + + beat = pix[0] | pix[1] << w | ... | pix[N-1] << (N-1)*w + + where w = per-pixel tdata_width and pix[k] is the k-th per-pixel packed + value from render_frame(). width MUST be a multiple of N (the RTL clamps + it down to one; here we require it so the reference is unambiguous). + + Returns a list of beat values, len = (width // N) * height. + """ + n = cfg.pixels_per_clock + if n <= 1: + return render_frame(cfg, regs) + if cfg.width % n != 0: + raise ValueError( + f"width={cfg.width} must be a multiple of pixels_per_clock={n}") + per_pixel = render_frame(cfg, regs) # width*height per-pixel words + w = cfg.tdata_width + beats: list[int] = [] + for base in range(0, len(per_pixel), n): + acc = 0 + for lane in range(n): + acc |= (per_pixel[base + lane] & ((1 << w) - 1)) << (lane * w) + beats.append(acc) + return beats + + def tdata_to_bram_words(tdata_list: Iterable[int], tdata_width: int = 32) -> list[int]: """Flatten a list of tdata beats to little-endian 32-bit words. diff --git a/rtl/vtpgz_axil_regs.v b/rtl/vtpgz_axil_regs.v index 7da72a3..a848e05 100644 --- a/rtl/vtpgz_axil_regs.v +++ b/rtl/vtpgz_axil_regs.v @@ -16,6 +16,7 @@ module vtpgz_axil_regs #( parameter RAW_BAYER = `VTPGZ_RAW_RGGB, parameter RGB_ORDER = `VTPGZ_RGB_ORDER_XILINX, parameter BPC = 8, + parameter integer PIXELS_PER_CLOCK = 1, parameter TDATA_WIDTH = 24 )( input wire aclk, @@ -227,6 +228,7 @@ module vtpgz_axil_regs #( `VTPGZ_REG_IMG_WIDTH : s_axi_rdata <= reg_img_width; `VTPGZ_REG_IMG_HEIGHT : s_axi_rdata <= reg_img_height; `VTPGZ_REG_PATTERN_SEL : s_axi_rdata <= reg_pattern_sel; + `VTPGZ_REG_PIXELS_PER_CLOCK : s_axi_rdata <= PIXELS_PER_CLOCK; `VTPGZ_REG_COLOR_FORMAT : s_axi_rdata <= { TDATA_WIDTH[15:0], BPC[7:0], diff --git a/rtl/vtpgz_axilite_top.v b/rtl/vtpgz_axilite_top.v index 1ee674e..378d18f 100644 --- a/rtl/vtpgz_axilite_top.v +++ b/rtl/vtpgz_axilite_top.v @@ -43,14 +43,16 @@ module vtpgz_axilite_top #( parameter RAW_BAYER = `VTPGZ_RAW_RGGB, parameter RGB_ORDER = `VTPGZ_RGB_ORDER_XILINX, parameter BPC = 8, + parameter integer PIXELS_PER_CLOCK = 1, parameter integer LINE_GAP_CYCLES = 1, - // ----- derived AXI-Stream tdata width (same formula as in core) ----- - parameter C_AXIS_TDATA_WIDTH = + // ----- derived tdata widths (same formulas as in core) ----- + parameter PIX_TDATA_WIDTH = (OUTPUT_MODE == `VTPGZ_MODE_RGB) ? (((3*BPC + 7) / 8) * 8) : (OUTPUT_MODE == `VTPGZ_MODE_RAW) ? ((( BPC + 7) / 8) * 8) : /* MODE_YUV */ (YUV_SUBSAMPLE == `VTPGZ_YUV_444 ? (((3*BPC + 7) / 8) * 8) - : (((2*BPC + 7) / 8) * 8)) + : (((2*BPC + 7) / 8) * 8)), + parameter C_AXIS_TDATA_WIDTH = PIXELS_PER_CLOCK * PIX_TDATA_WIDTH )( input wire aclk, input wire aresetn, @@ -122,6 +124,7 @@ module vtpgz_axilite_top #( .RAW_BAYER (RAW_BAYER), .RGB_ORDER (RGB_ORDER), .BPC (BPC), + .PIXELS_PER_CLOCK(PIXELS_PER_CLOCK), .TDATA_WIDTH (C_AXIS_TDATA_WIDTH) ) u_regs ( .aclk (aclk), @@ -198,7 +201,9 @@ module vtpgz_axilite_top #( .RAW_BAYER (RAW_BAYER), .RGB_ORDER (RGB_ORDER), .BPC (BPC), + .PIXELS_PER_CLOCK(PIXELS_PER_CLOCK), .LINE_GAP_CYCLES(LINE_GAP_CYCLES), + .PIX_TDATA_WIDTH(PIX_TDATA_WIDTH), .C_AXIS_TDATA_WIDTH(C_AXIS_TDATA_WIDTH) ) u_core ( .aclk (aclk), diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index 9c05ce0..93fcb32 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -82,20 +82,36 @@ module vtpgz_core #( // 12-bit; for BPC<=12 the pack stage truncates LSBs, for BPC>12 it // zero-extends LSBs (the upper 12 bits carry the pattern data). parameter BPC = 8, + // ----- pixels emitted per AXI-Stream beat (build-time) ----- + // 1 (default) classic one-pixel-per-beat, netlist identical to prior + // releases. + // 2 / 4 / 8 pack that many horizontally-adjacent pixels into one wider + // beat, lane 0 (leftmost pixel) in the tdata LSBs. cfg_img_width + // is clamped down to a multiple of PIXELS_PER_CLOCK. + // + // NOTE (M1 scope): at PIXELS_PER_CLOCK>1 only the position-combinational + // patterns are supported -- SOLID, GRID, CHECKER, plus the moving-box + // overlay. The accumulator/counter/stateful patterns (COLORBAR, HGRAD, + // VGRAD, RAMP, NOISE, IMAGE, BOX_IMAGE) require PIXELS_PER_CLOCK==1 and + // their EN_* must be 0 for a PPC>1 build -- enforced by an elaboration + // check below (g_ppc_guard). + parameter integer PIXELS_PER_CLOCK = 1, // ----- AXI4-Stream video line pacing ----- // Insert this many TVALID-low cycles after each non-final TLAST before // emitting the next line. Values below 1 are clamped to the mandatory // one-cycle minimum gap. parameter integer LINE_GAP_CYCLES = 1, - // ----- derived AXI-Stream tdata width (do NOT override unless you know - // what you're doing; the default is the smallest multiple-of-8 that - // holds the active components for the chosen mode) ----- - parameter C_AXIS_TDATA_WIDTH = + // ----- derived per-PIXEL tdata width: the smallest multiple-of-8 that + // holds the active components for the chosen mode/bpc (do NOT override) -- + parameter PIX_TDATA_WIDTH = (OUTPUT_MODE == `VTPGZ_MODE_RGB) ? (((3*BPC + 7) / 8) * 8) : (OUTPUT_MODE == `VTPGZ_MODE_RAW) ? ((( BPC + 7) / 8) * 8) : /* MODE_YUV */ (YUV_SUBSAMPLE == `VTPGZ_YUV_444 ? (((3*BPC + 7) / 8) * 8) - : (((2*BPC + 7) / 8) * 8)) + : (((2*BPC + 7) / 8) * 8)), + // ----- derived AXI-Stream beat width = PIXELS_PER_CLOCK per-pixel slots + // (do NOT override) ----- + parameter C_AXIS_TDATA_WIDTH = PIXELS_PER_CLOCK * PIX_TDATA_WIDTH )( input wire aclk, input wire aresetn, @@ -142,6 +158,30 @@ module vtpgz_core #( input wire frame_sync_in ); + // ---------------- pixels-per-clock shorthands ---------------- + localparam integer NPPC = PIXELS_PER_CLOCK; + + // ---------------- elaboration guards ---------------- + // PIXELS_PER_CLOCK must be one of 1/2/4/8, and (M1 scope) a PPC>1 build + // may only enable the position-combinational patterns. Any violation + // instantiates an undefined module so elaboration fails loudly with the + // offending name, rather than silently producing wrong pixels. + generate + if (!(NPPC == 1 || NPPC == 2 || NPPC == 4 || NPPC == 8)) begin : g_ppc_bad + VTPGZ_PIXELS_PER_CLOCK_MUST_BE_1_2_4_OR_8 guard(); + end + if (NPPC != 1) begin : g_ppc_guard + if (EN_COLORBAR || EN_HGRAD || EN_VGRAD || EN_RAMP || + EN_NOISE || EN_IMAGE || EN_BOX_IMAGE) begin : g_unsupported + VTPGZ_PPC_GT1_SUPPORTS_ONLY_SOLID_GRID_CHECKER_BOX guard(); + end + end + endgenerate + + // Bit-shrink/grow shift amounts (constant): mirror the BPC pack stage. + localparam integer SHIFT_DN = (BPC <= 12) ? (12 - BPC) : 0; // truncate LSBs + localparam integer SHIFT_UP = (BPC > 12) ? (BPC - 12) : 0; // zero-extend LSBs + // ---------------- effective configuration ---------------- // Clamp unsafe zero / over-large geometry values so malformed software // writes cannot underflow the timing or moving-box arithmetic. @@ -152,14 +192,20 @@ module vtpgz_core #( // pulling cfg_img_* onto the box_y/box_x reset path with high-fanout // shared LUTs. cfg_img_* is host-programmed before cfg_enable, so a // 1-cycle clamp latency is harmless. + // Width is clamped DOWN to a multiple of NPPC (and to at least NPPC) so a + // whole number of beats covers each line; at NPPC==1 the mask is 0xFFFF + // and this reduces to the original "zero -> 1" clamp exactly. + localparam [15:0] WIDTH_ALIGN_MASK = 16'hFFFF - (NPPC - 1); reg [15:0] img_width_eff; reg [15:0] img_height_eff; always @(posedge aclk) begin if (!aresetn) begin - img_width_eff <= 16'h1; + img_width_eff <= NPPC[15:0]; img_height_eff <= 16'h1; end else begin - img_width_eff <= (cfg_img_width == 16'h0) ? 16'h1 : cfg_img_width; + img_width_eff <= ((cfg_img_width & WIDTH_ALIGN_MASK) == 16'h0) + ? NPPC[15:0] + : (cfg_img_width & WIDTH_ALIGN_MASK); img_height_eff <= (cfg_img_height == 16'h0) ? 16'h1 : cfg_img_height; end end @@ -261,7 +307,10 @@ module vtpgz_core #( (!m_axis_tvalid || m_axis_tready) && !axis_gap_start && !axis_gap_active; wire source_advance = active && axis_can_advance; - wire last_x = (x == img_width_eff - 16'd1); + // At NPPC pixels/beat the base counter x holds lane 0's coordinate and + // advances by NPPC; the final beat of a line starts NPPC pixels before + // the width (width is a multiple of NPPC). At NPPC==1 this is (x==W-1). + wire last_x = (x == img_width_eff - NPPC[15:0]); wire last_y = (y == img_height_eff - 16'd1); wire end_of_frame = last_x & last_y; @@ -312,7 +361,7 @@ module vtpgz_core #( x <= 16'h0; y <= y + 16'h1; end else begin - x <= x + 16'h1; + x <= x + NPPC[15:0]; end end end @@ -340,6 +389,14 @@ module vtpgz_core #( wire [11:0] image_r, image_g, image_b; wire [11:0] box_img_r, box_img_g, box_img_b; + // ---- Per-lane pattern buses (only meaningful for the M1 patterns) ---- + // Lane l (l=0..NPPC-1) occupies bits [12*l +: 12]. Lane 0 always equals + // the corresponding scalar wire above, so the existing NPPC==1 pattern + // mux/pipeline is byte-identical. Lanes 1..NPPC-1 carry the value the + // per-pixel path would produce at (x+l, y) and only exist for NPPC>1. + wire [12*NPPC-1:0] chk_v_bus; + wire [12*NPPC-1:0] grid_r_bus, grid_g_bus, grid_b_bus; + // ---- Color bars (8 SMPTE bars) ---- // Counter-based: increment bar index every cfg_bar_width pixels. // Host writes BAR_WIDTH = img_width/8 once per resolution change. @@ -454,8 +511,25 @@ module vtpgz_core #( // each time they reach cfg_checker_size. No divider, no modulo. generate if (EN_CHECKER) begin : g_checker wire [15:0] chk_size_eff = (cfg_checker_size == 16'h0) ? 16'h1 : cfg_checker_size; - reg [15:0] chk_x_cnt, chk_y_cnt; + reg [15:0] chk_x_cnt, chk_y_cnt; // base = lane 0 state at pixel x reg chk_sel_x, chk_sel_y; + // Per-lane x-axis chain: lane 0 = base regs, lane l = one per-pixel + // "single-step" of lane l-1. cxc[NPPC]/cxs[NPPC] is the state after + // all NPPC lanes = the base for the next beat. At NPPC==1 this is a + // single step and collapses to the original recurrence exactly. + wire [15:0] cxc [0:NPPC]; + wire cxs [0:NPPC]; + assign cxc[0] = chk_x_cnt; + assign cxs[0] = chk_sel_x; + genvar gl; + for (gl = 0; gl < NPPC; gl = gl + 1) begin : g_chk_chain + wire wrap_l = (cxc[gl] + 16'h1 >= chk_size_eff); + assign cxc[gl+1] = wrap_l ? 16'h0 : (cxc[gl] + 16'h1); + assign cxs[gl+1] = wrap_l ? ~cxs[gl] : cxs[gl]; + // lane gl value uses cxs[gl] (state for pixel x+gl) + assign chk_v_bus[12*gl +: 12] = + (cxs[gl] ^ chk_sel_y) ? 12'hFFF : 12'h000; + end always @(posedge aclk) begin if (!aresetn || frame_init) begin chk_x_cnt <= 16'h0; @@ -464,17 +538,16 @@ module vtpgz_core #( chk_sel_y <= 1'b0; end else if (source_advance) begin // X axis -- anchor reset on last_x of the previous line - // (same reasoning as colorbar / hgrad). + // (same reasoning as colorbar / hgrad). Otherwise advance the + // base by NPPC pixels via the chain's final state. if (last_x) begin chk_x_cnt <= 16'h0; chk_sel_x <= 1'b0; - end else if (chk_x_cnt + 16'h1 >= chk_size_eff) begin - chk_x_cnt <= 16'h0; - chk_sel_x <= ~chk_sel_x; end else begin - chk_x_cnt <= chk_x_cnt + 16'h1; + chk_x_cnt <= cxc[NPPC]; + chk_sel_x <= cxs[NPPC]; end - // Y axis (per-line) + // Y axis (per-line) -- unchanged by NPPC. if (pix_sof) begin chk_y_cnt <= 16'h0; chk_sel_y <= 1'b0; @@ -488,9 +561,10 @@ module vtpgz_core #( end end end - assign chk_v = (chk_sel_x ^ chk_sel_y) ? 12'hFFF : 12'h000; + assign chk_v = chk_v_bus[11:0]; // lane 0 end else begin : g_checker_off - assign chk_v = 12'h000; + assign chk_v = 12'h000; + assign chk_v_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- Solid color from register ---- @@ -513,7 +587,8 @@ module vtpgz_core #( // The box state is reset on frame_init so each fresh enable starts // with the box at (0,0). This matches the Python model which // constructs a fresh VtpgzRegs per render_frame call. - wire box_in; + wire box_in; // lane 0 (== box_in_bus[0]) + wire [NPPC-1:0] box_in_bus; // per-lane box-region membership generate if (EN_MOVING_BOX) begin : g_box reg [15:0] box_x; reg [15:0] box_y; @@ -584,25 +659,42 @@ module vtpgz_core #( end end end - assign box_in = (x >= box_x) && (x < box_x + box_width_eff) && - (y >= box_y) && (y < box_y + box_height_eff); + // Per-lane box membership: lane gl tests column (x+gl). The y test is + // common to all lanes in a beat. At NPPC==1 lane 0 is exactly the + // original expression. + wire box_in_y = (y >= box_y) && (y < box_y + box_height_eff); + genvar gbl; + for (gbl = 0; gbl < NPPC; gbl = gbl + 1) begin : g_box_lane + wire [15:0] xl = x + gbl[15:0]; + assign box_in_bus[gbl] = (xl >= box_x) && (xl < box_x + box_width_eff) && + box_in_y; + end + assign box_in = box_in_bus[0]; end else begin : g_box_off - assign box_in = 1'b0; + assign box_in = 1'b0; + assign box_in_bus = {NPPC{1'b0}}; end endgenerate // ---- Grid / crosshatch ---- // Wrap-counters per axis, "on grid" when counter is at zero. generate if (EN_GRID) begin : g_grid wire [15:0] grid_eff = (cfg_grid_spacing == 16'h0) ? 16'h1 : cfg_grid_spacing; - reg [15:0] gx_cnt, gy_cnt; + reg [15:0] gx_cnt, gy_cnt; // base = lane 0 x-counter at pixel x + // Per-lane x-axis chain (see checker for the recurrence rationale). + wire [15:0] gxc [0:NPPC]; + assign gxc[0] = gx_cnt; + genvar gl; + for (gl = 0; gl < NPPC; gl = gl + 1) begin : g_grid_chain + assign gxc[gl+1] = (gxc[gl] + 16'h1 >= grid_eff) ? 16'h0 + : (gxc[gl] + 16'h1); + end always @(posedge aclk) begin if (!aresetn || frame_init) begin gx_cnt <= 16'h0; gy_cnt <= 16'h0; end else if (source_advance) begin if (last_x) gx_cnt <= 16'h0; - else if (gx_cnt + 16'h1 >= grid_eff) gx_cnt <= 16'h0; - else gx_cnt <= gx_cnt + 16'h1; + else gx_cnt <= gxc[NPPC]; if (pix_sof) gy_cnt <= 16'h0; else if (last_x) begin @@ -611,20 +703,32 @@ module vtpgz_core #( end end end - wire on_grid = (gx_cnt == 16'h0) || (gy_cnt == 16'h0); // Off-grid background must be {Y=0, Cb=neutral, Cr=neutral} in YUV mode, // otherwise the receiver's YCbCr->RGB renders {0,0,0} as ~(0,135,0) // green. The other gray-style patterns get the same treatment in the // _c1/_c2 helpers below. wire [11:0] bg_c1 = (OUTPUT_MODE == `VTPGZ_MODE_YUV) ? 12'h800 : 12'h000; wire [11:0] bg_c2 = (OUTPUT_MODE == `VTPGZ_MODE_YUV) ? 12'h800 : 12'h000; - assign grid_r = on_grid ? {cfg_grid_color[23:16],4'h0} : 12'h000; - assign grid_g = on_grid ? {cfg_grid_color[15:8], 4'h0} : bg_c1; - assign grid_b = on_grid ? {cfg_grid_color[7:0], 4'h0} : bg_c2; + for (gl = 0; gl < NPPC; gl = gl + 1) begin : g_grid_lane + wire on_grid_l = (gxc[gl] == 16'h0) || (gy_cnt == 16'h0); + assign grid_r_bus[12*gl +: 12] = on_grid_l ? {cfg_grid_color[23:16],4'h0} : 12'h000; + assign grid_g_bus[12*gl +: 12] = on_grid_l ? {cfg_grid_color[15:8], 4'h0} : bg_c1; + assign grid_b_bus[12*gl +: 12] = on_grid_l ? {cfg_grid_color[7:0], 4'h0} : bg_c2; + end + assign grid_r = grid_r_bus[11:0]; // lane 0 + assign grid_g = grid_g_bus[11:0]; + assign grid_b = grid_b_bus[11:0]; end else begin : g_grid_off assign grid_r = 12'h000; assign grid_g = (OUTPUT_MODE == `VTPGZ_MODE_YUV) ? 12'h800 : 12'h000; assign grid_b = (OUTPUT_MODE == `VTPGZ_MODE_YUV) ? 12'h800 : 12'h000; + // Off-lanes replicate the lane-0 background across the bus. + genvar gof; + for (gof = 0; gof < NPPC; gof = gof + 1) begin : g_grid_off_bus + assign grid_r_bus[12*gof +: 12] = 12'h000; + assign grid_g_bus[12*gof +: 12] = (OUTPUT_MODE == `VTPGZ_MODE_YUV) ? 12'h800 : 12'h000; + assign grid_b_bus[12*gof +: 12] = (OUTPUT_MODE == `VTPGZ_MODE_YUV) ? 12'h800 : 12'h000; + end end endgenerate // ---- Ramp ---- @@ -889,6 +993,33 @@ module vtpgz_core #( endcase end + // ---- Per-lane pattern mux (NPPC>1 only) ---- + // Lanes 1..NPPC-1 select among the M1 patterns (SOLID / GRID / CHECKER) + // for column (x+l). Lane 0 uses the full pat_c0/c1/c2 mux above, so the + // NPPC==1 datapath is untouched. The stateful/accumulator patterns are + // elaboration-forbidden at NPPC>1 (g_ppc_guard), so a black default here + // is never selected in a legal build. + wire [12*NPPC-1:0] pat_c0_bus, pat_c1_bus, pat_c2_bus; + genvar gpl; + generate for (gpl = 0; gpl < NPPC; gpl = gpl + 1) begin : g_pat_lane + wire [11:0] chkl = chk_v_bus[12*gpl +: 12]; + wire [11:0] chkl_c = is_yuv_build ? CHROMA_NEUTRAL : chkl; + reg [11:0] p0, p1, p2; + always @* begin + case (cfg_pattern) + `VTPGZ_PAT_CHECKER : begin p0 = chkl; p1 = chkl_c; p2 = chkl_c; end + `VTPGZ_PAT_SOLID : begin p0 = solid_r; p1 = solid_g; p2 = solid_b; end + `VTPGZ_PAT_GRID : begin p0 = grid_r_bus[12*gpl +: 12]; + p1 = grid_g_bus[12*gpl +: 12]; + p2 = grid_b_bus[12*gpl +: 12]; end + default : begin p0 = 12'h0; p1 = 12'h0; p2 = 12'h0; end + endcase + end + assign pat_c0_bus[12*gpl +: 12] = p0; + assign pat_c1_bus[12*gpl +: 12] = p1; + assign pat_c2_bus[12*gpl +: 12] = p2; + end endgenerate + // ---- Box overlay (post-mux) ---- // When EN_MOVING_BOX=1 and the current pixel is inside the box // region, the pattern output is replaced with cfg_box_color (fill) @@ -907,8 +1038,9 @@ module vtpgz_core #( // Border test: pixel is on the border ring if it's within // border_width of any box edge (but still inside box_in). // verilator coverage_off - wire box_on_border; + wire box_on_border; // lane 0 (== box_on_border_bus[0]) // verilator coverage_on + wire [NPPC-1:0] box_on_border_bus; // per-lane border-ring membership generate if (EN_MOVING_BOX) begin : g_box_border wire [15:0] bw_raw = {8'h0, cfg_box_border_width}; wire [15:0] bw_x = (bw_raw > box_width_eff) ? box_width_eff : bw_raw; @@ -921,14 +1053,21 @@ module vtpgz_core #( // cfg_img_* onto this path with fan-outs of 30+ that routed // poorly under heavy congestion. Equivalent for any non-overflowing // input (x + bw < 2^16, satisfied by all practical resolutions). - assign box_on_border = box_in && ( - (x < g_box.box_x + bw_x) || - (x + bw_x >= g_box.box_x + box_width_eff) || - (y < g_box.box_y + bw_y) || - (y + bw_y >= g_box.box_y + box_height_eff) - ); + // Per-lane: lane gl tests column (x+gl); the y-edge terms are common. + genvar gbb; + for (gbb = 0; gbb < NPPC; gbb = gbb + 1) begin : g_border_lane + wire [15:0] xl = x + gbb[15:0]; + assign box_on_border_bus[gbb] = box_in_bus[gbb] && ( + (xl < g_box.box_x + bw_x) || + (xl + bw_x >= g_box.box_x + box_width_eff) || + (y < g_box.box_y + bw_y) || + (y + bw_y >= g_box.box_y + box_height_eff) + ); + end + assign box_on_border = box_on_border_bus[0]; end else begin : g_box_border_off - assign box_on_border = 1'b0; + assign box_on_border = 1'b0; + assign box_on_border_bus = {NPPC{1'b0}}; end endgenerate // ---------------- pipeline stage 1 (pre-mux register) ---------------- @@ -937,22 +1076,27 @@ module vtpgz_core #( // final pix_c0/c1/c2 mux. Registering box_in / box_on_border here // caps that path at the adders+compares and leaves only a 3:1 mux // feeding pix_cN_q in stage 2. - reg box_in_s1, box_on_border_s1; - reg [11:0] pat_c0_s1, pat_c1_s1, pat_c2_s1; + // Per-lane at NPPC>1: lane l occupies bit l (flags) / [12*l +: 12] (triples). + // Lane 0 latches the existing scalar signals so the NPPC==1 path is + // byte-identical; lanes 1..NPPC-1 latch the per-lane buses. + reg [NPPC-1:0] box_in_s1, box_on_border_s1; + reg [12*NPPC-1:0] pat_c0_s1, pat_c1_s1, pat_c2_s1; // Box-image colours land in their own s1 registers so the mux at this // stage gets a value computed from the SAME cycle's x,y as box_in_s1 // (the combinational box_img_* would otherwise be one cycle ahead). + // Box-image is a NPPC==1-only feature (elaboration-forbidden at NPPC>1), + // so these remain scalar and only feed lane 0's inside-mux. reg [11:0] box_img_r_s1, box_img_g_s1, box_img_b_s1; reg pix_valid_s1, pix_sof_s1, pix_eol_s1, pix_eof_s1; reg pix_x_lsb_s1, pix_y_lsb_s1; wire pipe_advance; always @(posedge aclk) begin if (!aresetn) begin - box_in_s1 <= 1'b0; - box_on_border_s1 <= 1'b0; - pat_c0_s1 <= 12'h0; - pat_c1_s1 <= 12'h0; - pat_c2_s1 <= 12'h0; + box_in_s1 <= {NPPC{1'b0}}; + box_on_border_s1 <= {NPPC{1'b0}}; + pat_c0_s1 <= {(12*NPPC){1'b0}}; + pat_c1_s1 <= {(12*NPPC){1'b0}}; + pat_c2_s1 <= {(12*NPPC){1'b0}}; box_img_r_s1 <= 12'h0; box_img_g_s1 <= 12'h0; box_img_b_s1 <= 12'h0; @@ -963,11 +1107,12 @@ module vtpgz_core #( pix_x_lsb_s1 <= 1'b0; pix_y_lsb_s1 <= 1'b0; end else if (pipe_advance) begin - box_in_s1 <= box_in; - box_on_border_s1 <= box_on_border; - pat_c0_s1 <= pat_c0; - pat_c1_s1 <= pat_c1; - pat_c2_s1 <= pat_c2; + // lane 0 from the existing scalar mux/comparators + box_in_s1[0] <= box_in; + box_on_border_s1[0] <= box_on_border; + pat_c0_s1[11:0] <= pat_c0; + pat_c1_s1[11:0] <= pat_c1; + pat_c2_s1[11:0] <= pat_c2; box_img_r_s1 <= box_img_r; box_img_g_s1 <= box_img_g; box_img_b_s1 <= box_img_b; @@ -979,6 +1124,27 @@ module vtpgz_core #( pix_y_lsb_s1 <= y[0]; end end + // lanes 1..NPPC-1 latch the per-lane buses (stripped when NPPC==1) + generate + genvar gs1; + for (gs1 = 1; gs1 < NPPC; gs1 = gs1 + 1) begin : g_s1_lane + always @(posedge aclk) begin + if (!aresetn) begin + box_in_s1[gs1] <= 1'b0; + box_on_border_s1[gs1] <= 1'b0; + pat_c0_s1[12*gs1 +: 12] <= 12'h0; + pat_c1_s1[12*gs1 +: 12] <= 12'h0; + pat_c2_s1[12*gs1 +: 12] <= 12'h0; + end else if (pipe_advance) begin + box_in_s1[gs1] <= box_in_bus[gs1]; + box_on_border_s1[gs1] <= box_on_border_bus[gs1]; + pat_c0_s1[12*gs1 +: 12] <= pat_c0_bus[12*gs1 +: 12]; + pat_c1_s1[12*gs1 +: 12] <= pat_c1_bus[12*gs1 +: 12]; + pat_c2_s1[12*gs1 +: 12] <= pat_c2_bus[12*gs1 +: 12]; + end + end + end + endgenerate // When EN_BOX_IMAGE=1 the box interior shows the scaled image instead // of cfg_box_color -- but only while the host has programmed non-zero @@ -987,30 +1153,50 @@ module vtpgz_core #( // border_width > 0) still wins because box_on_border_s1 is checked // first. wire box_image_active = (EN_BOX_IMAGE != 0) && (cfg_box_img_x_step != 32'h0); + // Lane 0's box interior can show the scaled box-image (NPPC==1 only); + // lanes 1..NPPC-1 always show the solid fill (box-image is forbidden at + // NPPC>1, so this is exact, not an approximation). wire [11:0] box_inside_c0 = box_image_active ? box_img_r_s1 : box_fill_c0; wire [11:0] box_inside_c1 = box_image_active ? box_img_g_s1 : box_fill_c1; wire [11:0] box_inside_c2 = box_image_active ? box_img_b_s1 : box_fill_c2; - wire [11:0] pix_c0 = box_on_border_s1 ? box_bdr_c0 : - box_in_s1 ? box_inside_c0 : pat_c0_s1; - wire [11:0] pix_c1 = box_on_border_s1 ? box_bdr_c1 : - box_in_s1 ? box_inside_c1 : pat_c1_s1; - wire [11:0] pix_c2 = box_on_border_s1 ? box_bdr_c2 : - box_in_s1 ? box_inside_c2 : pat_c2_s1; + // Per-lane composited pixel. Lane 0 uses box_inside_c* (with box-image); + // at NPPC==1 pix_c*_bus[11:0] is exactly the original pix_c*. + wire [12*NPPC-1:0] pix_c0_bus, pix_c1_bus, pix_c2_bus; + assign pix_c0_bus[11:0] = box_on_border_s1[0] ? box_bdr_c0 : + box_in_s1[0] ? box_inside_c0 : pat_c0_s1[11:0]; + assign pix_c1_bus[11:0] = box_on_border_s1[0] ? box_bdr_c1 : + box_in_s1[0] ? box_inside_c1 : pat_c1_s1[11:0]; + assign pix_c2_bus[11:0] = box_on_border_s1[0] ? box_bdr_c2 : + box_in_s1[0] ? box_inside_c2 : pat_c2_s1[11:0]; + generate + genvar gpc; + for (gpc = 1; gpc < NPPC; gpc = gpc + 1) begin : g_pix_lane + assign pix_c0_bus[12*gpc +: 12] = box_on_border_s1[gpc] ? box_bdr_c0 : + box_in_s1[gpc] ? box_fill_c0 + : pat_c0_s1[12*gpc +: 12]; + assign pix_c1_bus[12*gpc +: 12] = box_on_border_s1[gpc] ? box_bdr_c1 : + box_in_s1[gpc] ? box_fill_c1 + : pat_c1_s1[12*gpc +: 12]; + assign pix_c2_bus[12*gpc +: 12] = box_on_border_s1[gpc] ? box_bdr_c2 : + box_in_s1[gpc] ? box_fill_c2 + : pat_c2_s1[12*gpc +: 12]; + end + endgenerate // ---------------- pipeline stage 2 (post-mux register) --------------- // Two pipeline stages total between pattern/box state and the AXI // output: SOF → first pixel on AXI is delayed by 2 cycles vs. a // combinational core, but the per-beat value sequence is unchanged. - (* keep = "true", dont_touch = "true" *) reg [11:0] pix_c0_q, pix_c1_q, pix_c2_q; + (* keep = "true", dont_touch = "true" *) reg [12*NPPC-1:0] pix_c0_q, pix_c1_q, pix_c2_q; reg pix_valid_q, pix_sof_q, pix_eol_q, pix_eof_q; reg pix_x_lsb_q, pix_y_lsb_q; assign pipe_advance = axis_can_advance && (active || pix_valid_s1 || pix_valid_q); always @(posedge aclk) begin if (!aresetn) begin - pix_c0_q <= 12'h0; - pix_c1_q <= 12'h0; - pix_c2_q <= 12'h0; + pix_c0_q <= {(12*NPPC){1'b0}}; + pix_c1_q <= {(12*NPPC){1'b0}}; + pix_c2_q <= {(12*NPPC){1'b0}}; pix_valid_q <= 1'b0; pix_sof_q <= 1'b0; pix_eol_q <= 1'b0; @@ -1018,9 +1204,9 @@ module vtpgz_core #( pix_x_lsb_q <= 1'b0; pix_y_lsb_q <= 1'b0; end else if (pipe_advance) begin - pix_c0_q <= pix_c0; - pix_c1_q <= pix_c1; - pix_c2_q <= pix_c2; + pix_c0_q <= pix_c0_bus; + pix_c1_q <= pix_c1_bus; + pix_c2_q <= pix_c2_bus; pix_valid_q <= pix_valid_s1; pix_sof_q <= pix_sof_s1; pix_eol_q <= pix_eol_s1; @@ -1047,99 +1233,76 @@ module vtpgz_core #( // BPC < 12 -> take top BPC bits (truncate LSBs) // BPC > 12 -> zero-extend on the right (BPC-12 zero LSBs) // No DSPs in any case. - wire [BPC-1:0] c0_s, c1_s, c2_s; - generate - if (BPC == 12) begin : g_bpc_pass - assign c0_s = pix_c0_q; - assign c1_s = pix_c1_q; - assign c2_s = pix_c2_q; - end else if (BPC < 12) begin : g_bpc_shrink - assign c0_s = pix_c0_q[11 -: BPC]; - assign c1_s = pix_c1_q[11 -: BPC]; - assign c2_s = pix_c2_q[11 -: BPC]; - end else begin : g_bpc_grow - assign c0_s = {pix_c0_q, {(BPC-12){1'b0}}}; - assign c1_s = {pix_c1_q, {(BPC-12){1'b0}}}; - assign c2_s = {pix_c2_q, {(BPC-12){1'b0}}}; + // pack_pixel: shrink/grow each 12-bit component to BPC and pack ONE pixel + // into a PIX_TDATA_WIDTH slot per the build-time mode. This is the exact + // logic that was previously three parallel `generate ... always` blocks, + // now a reusable function so each of the NPPC lanes packs independently. + // xlsb/ylsb are the packed pixel's (x[0], y[0]) parity used by the + // YUV422 chroma phase and the RAW Bayer 2x2 select. + function [PIX_TDATA_WIDTH-1:0] pack_pixel; + input [11:0] c0_12, c1_12, c2_12; + input xlsb, ylsb; + reg [BPC-1:0] c0, c1, c2, cc, raw_sel; + begin + // Bit-shrink (BPC<=12: truncate LSBs) / grow (BPC>12: zero-extend). + // Truncation to [BPC-1:0] happens on assignment; SHIFT_DN/SHIFT_UP + // are non-negative constants so both arms are legal for any BPC. + c0 = (BPC <= 12) ? (c0_12 >> SHIFT_DN) : (c0_12 << SHIFT_UP); + c1 = (BPC <= 12) ? (c1_12 >> SHIFT_DN) : (c1_12 << SHIFT_UP); + c2 = (BPC <= 12) ? (c2_12 >> SHIFT_DN) : (c2_12 << SHIFT_UP); + if (OUTPUT_MODE == `VTPGZ_MODE_RGB || + (OUTPUT_MODE == `VTPGZ_MODE_YUV && YUV_SUBSAMPLE == `VTPGZ_YUV_444)) begin + if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) + pack_pixel = {{(PIX_TDATA_WIDTH-3*BPC){1'b0}}, c2, c1, c0}; + else + pack_pixel = {c0, c1, c2, {(PIX_TDATA_WIDTH-3*BPC){1'b0}}}; + end else if (OUTPUT_MODE == `VTPGZ_MODE_YUV) begin + // 4:2:2 -- {Y, C}; C = Cb on even-x, Cr on odd-x + cc = (xlsb == 1'b0) ? c1 : c2; + if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) + pack_pixel = {{(PIX_TDATA_WIDTH-2*BPC){1'b0}}, cc, c0}; + else + pack_pixel = {c0, cc, {(PIX_TDATA_WIDTH-2*BPC){1'b0}}}; + end else begin + // RAW: single component, RAW_BAYER selects the 2x2 mosaic. + // PLAIN : monochrome, take G (c1) every pixel + // RGGB : row0:[R,G] row1:[G,B] BGGR : row0:[B,G] row1:[G,R] + // GRBG : row0:[G,R] row1:[B,G] GBRG : row0:[G,B] row1:[R,G] + case (RAW_BAYER) + `VTPGZ_RAW_RGGB: raw_sel = (ylsb==1'b0) ? ((xlsb==1'b0)?c0:c1) + : ((xlsb==1'b0)?c1:c2); + `VTPGZ_RAW_BGGR: raw_sel = (ylsb==1'b0) ? ((xlsb==1'b0)?c2:c1) + : ((xlsb==1'b0)?c1:c0); + `VTPGZ_RAW_GRBG: raw_sel = (ylsb==1'b0) ? ((xlsb==1'b0)?c1:c0) + : ((xlsb==1'b0)?c2:c1); + `VTPGZ_RAW_GBRG: raw_sel = (ylsb==1'b0) ? ((xlsb==1'b0)?c1:c2) + : ((xlsb==1'b0)?c0:c1); + default: raw_sel = c1; // PLAIN + endcase + pack_pixel = {{(PIX_TDATA_WIDTH-BPC){1'b0}}, raw_sel}; + end end - endgenerate + endfunction /*verilator coverage_off*/ reg [C_AXIS_TDATA_WIDTH-1:0] tdata_r; /*verilator coverage_on*/ reg tvalid_r; reg tlast_r; reg tuser_r; - // Combinational pack -> next-tdata - /*verilator coverage_off*/ reg [C_AXIS_TDATA_WIDTH-1:0] tdata_next; /*verilator coverage_on*/ + // Combinational pack -> next-tdata. Each lane packs into its own + // PIX_TDATA_WIDTH slot; lane 0 (leftmost pixel) lands in the LSBs. At + // NPPC==1 this is a single pack_pixel of lane 0 == the original tdata_next. + /*verilator coverage_off*/ wire [C_AXIS_TDATA_WIDTH-1:0] tdata_next; /*verilator coverage_on*/ generate - if (OUTPUT_MODE == `VTPGZ_MODE_RGB || (OUTPUT_MODE == `VTPGZ_MODE_YUV - && YUV_SUBSAMPLE == `VTPGZ_YUV_444)) begin : g_pack_3c - // verilator coverage_off - always @* begin - if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) begin - tdata_next = {{(C_AXIS_TDATA_WIDTH-3*BPC){1'b0}}, c2_s, c1_s, c0_s}; - end else begin - tdata_next = {c0_s, c1_s, c2_s, {(C_AXIS_TDATA_WIDTH-3*BPC){1'b0}}}; - end - end - // verilator coverage_on - end else if (OUTPUT_MODE == `VTPGZ_MODE_YUV) begin : g_pack_yuv422 - // 4:2:2 -- {Y, C} per beat, C = Cb on even-x, Cr on odd-x - // verilator coverage_off - wire [BPC-1:0] c_s = (pix_x_lsb_q == 1'b0) ? c1_s : c2_s; - always @* begin - if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) begin - tdata_next = {{(C_AXIS_TDATA_WIDTH-2*BPC){1'b0}}, c_s, c0_s}; - end else begin - tdata_next = {c0_s, c_s, {(C_AXIS_TDATA_WIDTH-2*BPC){1'b0}}}; - end - end - // verilator coverage_on - end else begin : g_pack_raw - // RAW: single component. RAW_BAYER selects the 2x2 mosaic. - // Triples are always {c0=R, c1=G, c2=B} in RAW mode (RGB - // semantics, like the RGB pack path -- the only difference - // is that here we mux one component per pixel based on the - // Bayer tile and (x[0], y[0])). - // - // PLAIN : monochrome, take G (c1) every pixel - // RGGB : row0:[R,G] row1:[G,B] - // BGGR : row0:[B,G] row1:[G,R] - // GRBG : row0:[G,R] row1:[B,G] - // GBRG : row0:[G,B] row1:[R,G] - // verilator coverage_off - reg [BPC-1:0] raw_sel; - always @* begin - case (RAW_BAYER) - `VTPGZ_RAW_RGGB: begin - if (pix_y_lsb_q == 1'b0) - raw_sel = (pix_x_lsb_q == 1'b0) ? c0_s : c1_s; - else - raw_sel = (pix_x_lsb_q == 1'b0) ? c1_s : c2_s; - end - `VTPGZ_RAW_BGGR: begin - if (pix_y_lsb_q == 1'b0) - raw_sel = (pix_x_lsb_q == 1'b0) ? c2_s : c1_s; - else - raw_sel = (pix_x_lsb_q == 1'b0) ? c1_s : c0_s; - end - `VTPGZ_RAW_GRBG: begin - if (pix_y_lsb_q == 1'b0) - raw_sel = (pix_x_lsb_q == 1'b0) ? c1_s : c0_s; - else - raw_sel = (pix_x_lsb_q == 1'b0) ? c2_s : c1_s; - end - `VTPGZ_RAW_GBRG: begin - if (pix_y_lsb_q == 1'b0) - raw_sel = (pix_x_lsb_q == 1'b0) ? c1_s : c2_s; - else - raw_sel = (pix_x_lsb_q == 1'b0) ? c0_s : c1_s; - end - default: raw_sel = c1_s; // PLAIN: G channel monochrome - endcase - tdata_next = {{(C_AXIS_TDATA_WIDTH-BPC){1'b0}}, raw_sel}; - end - // verilator coverage_on + genvar gpk; + for (gpk = 0; gpk < NPPC; gpk = gpk + 1) begin : g_pack_lane + // lane gpk parity: x_base[0] ^ (gpk&1); y is common to the beat. + assign tdata_next[PIX_TDATA_WIDTH*gpk +: PIX_TDATA_WIDTH] = + pack_pixel(pix_c0_q[12*gpk +: 12], + pix_c1_q[12*gpk +: 12], + pix_c2_q[12*gpk +: 12], + pix_x_lsb_q ^ (gpk & 1), + pix_y_lsb_q); end endgenerate diff --git a/rtl/vtpgz_defs.vh b/rtl/vtpgz_defs.vh index 81f98f6..05e78c7 100644 --- a/rtl/vtpgz_defs.vh +++ b/rtl/vtpgz_defs.vh @@ -8,13 +8,17 @@ `define VTPGZ_DEFS_VH // IP version -// 0.1.2 = current: configurable inter-line TVALID gap +// 0.2.0 = current: PIXELS_PER_CLOCK build param (1/2/4/8). M1 scope -- +// SOLID/GRID/CHECKER + moving-box overlay supported at PPC>1; +// other patterns require PPC=1 (elaboration-enforced). PPC +// mirrored RO at reg 0x30. PPC=1 netlist unchanged. +// 0.1.2: configurable inter-line TVALID gap // 0.1.1: colorbar state/AXIS alignment fix + regression // 0.1.0: box overlay + configurable border, no CSC, BPC 8-16, // 4 Bayer tiles, CORE_ID at 0x00, vtpgz_core split `define VTPGZ_VERSION_MAJOR 8'd0 -`define VTPGZ_VERSION_MINOR 8'd1 -`define VTPGZ_VERSION_PATCH 16'd2 +`define VTPGZ_VERSION_MINOR 8'd2 +`define VTPGZ_VERSION_PATCH 16'd0 // Register byte offsets (AXI4-Lite, 32-bit data) // 0x00 is a fixed core-identifier ASCII tag "VTPG" (in little-endian @@ -32,7 +36,7 @@ `define VTPGZ_REG_BOX_COLOR 8'h24 `define VTPGZ_REG_BOX_SIZE 8'h28 `define VTPGZ_REG_BOX_SPEED 8'h2C -`define VTPGZ_REG_RSVD_30 8'h30 // reserved — future use +`define VTPGZ_REG_PIXELS_PER_CLOCK 8'h30 // RO: build-time PIXELS_PER_CLOCK (1/2/4/8) `define VTPGZ_REG_GRID_SPACING 8'h34 `define VTPGZ_REG_GRID_COLOR 8'h38 `define VTPGZ_REG_CHECKER_SIZE 8'h3C diff --git a/sim/check_ppc_vs_model.py b/sim/check_ppc_vs_model.py new file mode 100644 index 0000000..e8270f5 --- /dev/null +++ b/sim/check_ppc_vs_model.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Leonardo Capossio - bard0 design - hello@bard0.com +# SPDX-License-Identifier: Apache-2.0 +""" +iverilog(RTL) <-> Python-model beat-exact gate for the pixels-per-clock (M1) +feature. Builds tb_ppc_capture.v once per (ppc, mode, bpc, ...) config with +iverilog -P overrides, sweeps the M1 patterns, and compares each captured +frame (one hex beat per line) against render_frame_beats(). + +Requires iverilog + vvp in PATH. Verilator is NOT needed here (that remains +the byte-exact gate for the PPC=1 baseline via sim/run_sim.py). + +Usage: + python sim/check_ppc_vs_model.py # sweep ppc x mode x pattern + python sim/check_ppc_vs_model.py --ppc 4 --mode rgb --bpc 8 +""" +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +RTL = (HERE / ".." / "rtl").resolve() +HW_PY = (HERE / ".." / "hw" / "arty_a7_100t" / "python").resolve() +sys.path.insert(0, str(HW_PY)) + +from vtpgz_model import ( # noqa: E402 + VtpgzConfig, render_frame_beats, + MODE_RGB, MODE_RAW, MODE_YUV, YUV_444, YUV_422, + RAW_PLAIN, RAW_RGGB, RAW_BGGR, RAW_GRBG, RAW_GBRG, + RGB_ORDER_XILINX, RGB_ORDER_LEGACY, + PAT_SOLID, PAT_GRID, PAT_CHECKER, +) + +MODE_MAP = {"rgb": MODE_RGB, "raw": MODE_RAW, "yuv": MODE_YUV} +SUB_MAP = {"444": YUV_444, "422": YUV_422} +BAYER_MAP = {"plain": RAW_PLAIN, "rggb": RAW_RGGB, "bggr": RAW_BGGR, + "grbg": RAW_GRBG, "gbrg": RAW_GBRG} +ORDER_MAP = {"xilinx": RGB_ORDER_XILINX, "legacy": RGB_ORDER_LEGACY} + +# Patterns exercised at PPC>1 (M1 scope) and the box-overlay (pattern SOLID +# with the box enabled is covered too, driven by the harness box_* config). +M1_PATTERNS = [("solid", PAT_SOLID), ("grid", PAT_GRID), ("checker", PAT_CHECKER)] + +# Must mirror the constant cfg_* the harness drives (tb_ppc_capture.v). +HARNESS_CFG = dict( + solid_color=0x3CA510, + box_color=0x00FF00, + box_width=12, box_height=8, box_dx=1, box_dy=1, + box_border_color=0xFF0000, box_border_width=2, + grid_spacing=7, grid_color=0xFFFFFF, + checker_size=5, + hg_step=64, vg_step=128, bar_width=8, +) + + +def need(tool: str) -> str: + p = shutil.which(tool) + if not p: + print(f"ERROR: '{tool}' not found in PATH", file=sys.stderr) + sys.exit(2) + return p + + +def build(ppc: int, mode: int, sub: int, bayer: int, order: int, bpc: int, + out_vvp: Path) -> None: + iverilog = need("iverilog") + top = "tb_ppc_capture" + params = { + "PIXELS_PER_CLOCK": ppc, "OUTPUT_MODE": mode, "YUV_SUBSAMPLE": sub, + "RAW_BAYER": bayer, "RGB_ORDER": order, "BPC": bpc, + } + cmd = [iverilog, "-g2001", "-Wall", "-I", str(RTL), "-s", top, + "-o", str(out_vvp)] + for k, v in params.items(): + cmd += ["-P", f"{top}.{k}={v}"] + cmd += [str(RTL / "vtpgz_core.v"), str(HERE / "tb_ppc_capture.v")] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError("iverilog build failed:\n" + r.stdout + r.stderr) + + +def run_capture(vvp_bin: Path, pat: int, width: int, height: int, + out_hex: Path) -> None: + vvp = need("vvp") + cmd = [vvp, str(vvp_bin), f"+pat={pat}", f"+width={width}", + f"+height={height}", f"+out={out_hex}"] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0 or "OK:" not in r.stdout: + raise RuntimeError(f"vvp capture failed (pat={pat}):\n{r.stdout}{r.stderr}") + + +def load_beats(path: Path) -> list[int]: + return [int(line, 16) for line in path.read_text().split() if line.strip()] + + +def model_beats(pat: int, ppc: int, mode: int, sub: int, bayer: int, + order: int, bpc: int, width: int, height: int) -> list[int]: + cfg = VtpgzConfig(width=width, height=height, pattern=pat, + output_mode=mode, yuv_subsample=sub, raw_bayer=bayer, + rgb_order=order, bpc=bpc, pixels_per_clock=ppc, + **HARNESS_CFG) + return render_frame_beats(cfg) + + +def check_one(ppc: int, mode_name: str, bpc: int, sub_name: str, + bayer_name: str, order_name: str, width: int, height: int, + tmp: Path) -> list[str]: + mode = MODE_MAP[mode_name] + sub = SUB_MAP[sub_name] + bayer = BAYER_MAP[bayer_name] + order = ORDER_MAP[order_name] + vvp_bin = tmp / f"ppc{ppc}_{mode_name}_{bpc}.vvp" + build(ppc, mode, sub, bayer, order, bpc, vvp_bin) + fails: list[str] = [] + for pname, pat in M1_PATTERNS: + hexf = tmp / f"cap_{ppc}_{mode_name}_{bpc}_{pname}.hex" + run_capture(vvp_bin, pat, width, height, hexf) + sim = load_beats(hexf) + mod = model_beats(pat, ppc, mode, sub, bayer, order, bpc, width, height) + if sim != mod: + first = next((i for i, (a, b) in enumerate(zip(sim, mod)) if a != b), + min(len(sim), len(mod))) + fails.append( + f"ppc={ppc} mode={mode_name} bpc={bpc} pat={pname}: " + f"len sim={len(sim)} mod={len(mod)} first_diff@{first} " + f"sim=0x{(sim[first] if first < len(sim) else 0):X} " + f"mod=0x{(mod[first] if first < len(mod) else 0):X}") + else: + print(f" OK ppc={ppc} mode={mode_name} bpc={bpc} pat={pname} " + f"({len(sim)} beats)") + return fails + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--ppc", type=int, default=None, choices=[1, 2, 4, 8]) + ap.add_argument("--mode", default=None, choices=list(MODE_MAP)) + ap.add_argument("--bpc", type=int, default=None, choices=[8, 10, 12, 14, 16]) + ap.add_argument("--yuv-sub", default="444", choices=list(SUB_MAP)) + ap.add_argument("--raw-bayer", default="rggb", choices=list(BAYER_MAP)) + ap.add_argument("--rgb-order", default="xilinx", choices=list(ORDER_MAP)) + ap.add_argument("--width", type=int, default=32) + ap.add_argument("--height", type=int, default=12) + args = ap.parse_args() + + ppcs = [args.ppc] if args.ppc else [1, 2, 4, 8] + modes = [args.mode] if args.mode else ["rgb", "raw", "yuv"] + bpcs = [args.bpc] if args.bpc else [8, 12, 16] + + all_fails: list[str] = [] + n = 0 + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + for ppc in ppcs: + # width must be a multiple of ppc; pick one that is for the sweep. + width = args.width + if width % ppc != 0: + width = (width // ppc) * ppc or ppc + for mode in modes: + for bpc in bpcs: + n += 1 + try: + all_fails += check_one(ppc, mode, bpc, args.yuv_sub, + args.raw_bayer, args.rgb_order, + width, args.height, tmp) + except RuntimeError as e: + all_fails.append(f"ppc={ppc} mode={mode} bpc={bpc}: {e}") + + print() + if all_fails: + print(f"FAIL: {len(all_fails)} mismatch(es) across {n} configs") + for f in all_fails[:20]: + print(" " + f) + return 1 + print(f"PASS: RTL <-> model beat-exact across {n} configs") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sim/tb_ppc_capture.v b/sim/tb_ppc_capture.v new file mode 100644 index 0000000..7651536 --- /dev/null +++ b/sim/tb_ppc_capture.v @@ -0,0 +1,197 @@ +//----------------------------------------------------------------------------- +// tb_ppc_capture.v - iverilog capture harness for pixels-per-clock (M1). +// +// Drives vtpgz_core (port-driven, no AXI-Lite) with configuration supplied +// via plusargs, captures exactly one frame of AXI-Stream beats, and writes +// each beat as one hex line to +out=. A companion Python script +// (check_ppc_vs_model.py) compares that against render_frame_beats(). +// +// Build-time PIXELS_PER_CLOCK / OUTPUT_MODE / BPC / pattern-enables are +// overridden per run via -P.= (iverilog) so one harness +// covers PPC=1/2/4/8 across the M1 patterns. +// +// SPDX-FileCopyrightText: 2026 Leonardo Capossio - bard0 design - hello@bard0.com +// SPDX-License-Identifier: Apache-2.0 +//----------------------------------------------------------------------------- +`timescale 1ns/1ps +`include "vtpgz_defs.vh" + +module tb_ppc_capture; + // Overridable build-time configuration (set via -P on the iverilog cmd line). + parameter integer PIXELS_PER_CLOCK = 1; + parameter integer OUTPUT_MODE = `VTPGZ_MODE_RGB; + parameter integer YUV_SUBSAMPLE = `VTPGZ_YUV_444; + parameter integer RAW_BAYER = `VTPGZ_RAW_RGGB; + parameter integer RGB_ORDER = `VTPGZ_RGB_ORDER_XILINX; + parameter integer BPC = 8; + // Only M1 patterns are legal at PPC>1, so the non-M1 enables default off. + // For a PPC=1 regression they can be turned on via -P to re-check the + // refactored pack stage against the model for every pattern. + parameter integer EN_SOLID = 1; + parameter integer EN_GRID = 1; + parameter integer EN_CHECKER = 1; + parameter integer EN_BOX = 1; + parameter integer EN_COLORBAR = 0; + parameter integer EN_HGRAD = 0; + parameter integer EN_VGRAD = 0; + parameter integer EN_RAMP = 0; + parameter integer EN_NOISE = 0; + + localparam integer PIX_TDATA_WIDTH = + (OUTPUT_MODE == `VTPGZ_MODE_RGB) ? (((3*BPC + 7) / 8) * 8) : + (OUTPUT_MODE == `VTPGZ_MODE_RAW) ? ((( BPC + 7) / 8) * 8) : + (YUV_SUBSAMPLE == `VTPGZ_YUV_444 ? (((3*BPC + 7) / 8) * 8) + : (((2*BPC + 7) / 8) * 8)); + localparam integer TDATA_WIDTH = PIXELS_PER_CLOCK * PIX_TDATA_WIDTH; + + reg aclk = 1'b0; + reg aresetn = 1'b0; + always #5 aclk = ~aclk; + + // config + reg [15:0] cfg_img_width; + reg [15:0] cfg_img_height; + reg [3:0] cfg_pattern; + reg cfg_enable; + + wire [TDATA_WIDTH-1:0] m_tdata; + wire m_tvalid; + wire m_tlast; + wire m_tuser; + reg m_tready; + + vtpgz_core #( + .EN_COLORBAR (EN_COLORBAR), + .EN_HGRAD (EN_HGRAD), + .EN_VGRAD (EN_VGRAD), + .EN_CHECKER (EN_CHECKER), + .EN_SOLID (EN_SOLID), + .EN_MOVING_BOX(EN_BOX), + .EN_GRID (EN_GRID), + .EN_RAMP (EN_RAMP), + .EN_NOISE (EN_NOISE), + .EN_IMAGE (0), + .EN_BOX_IMAGE (0), + .OUTPUT_MODE (OUTPUT_MODE), + .YUV_SUBSAMPLE(YUV_SUBSAMPLE), + .RAW_BAYER (RAW_BAYER), + .RGB_ORDER (RGB_ORDER), + .BPC (BPC), + .PIXELS_PER_CLOCK(PIXELS_PER_CLOCK), + .LINE_GAP_CYCLES(2) + ) dut ( + .aclk(aclk), .aresetn(aresetn), + .cfg_enable(cfg_enable), + .cfg_sw_fsync(1'b0), + .cfg_ext_sync(1'b0), + .cfg_img_width(cfg_img_width), + .cfg_img_height(cfg_img_height), + .cfg_pattern(cfg_pattern), + .cfg_solid_color(24'h3C_A5_10), + .cfg_box_color(24'h00_FF_00), + .cfg_box_width(16'd12), + .cfg_box_height(16'd8), + .cfg_box_dx(16'd1), + .cfg_box_dy(16'd1), + .cfg_grid_spacing(16'd7), + .cfg_grid_color(24'hFF_FF_FF), + .cfg_checker_size(16'd5), + .cfg_frame_rate_div(32'd50), + .cfg_bar_width(16'd8), + .cfg_hg_step(16'd64), + .cfg_vg_step(16'd128), + .cfg_box_border_color(24'hFF_00_00), + .cfg_box_border_width(8'd2), + .cfg_box_img_x_step(32'h0), + .cfg_box_img_y_step(32'h0), + .sts_busy(), + .sts_frame_count(), + .m_axis_tdata(m_tdata), + .m_axis_tvalid(m_tvalid), + .m_axis_tready(m_tready), + .m_axis_tlast(m_tlast), + .m_axis_tuser(m_tuser), + .frame_sync_in(1'b0) + ); + + integer out_fd; + integer i; + integer count; + integer total_beats; + integer max_cycles; + integer started; + integer width, height, pat; + integer n_tuser, n_tlast, bad_flag; + reg [1023:0] out_path; + + initial begin + if (!$value$plusargs("width=%d", width)) width = 32; + if (!$value$plusargs("height=%d", height)) height = 12; + if (!$value$plusargs("pat=%d", pat)) pat = `VTPGZ_PAT_SOLID; + if (!$value$plusargs("out=%s", out_path)) out_path = "ppc_cap.hex"; + + total_beats = (width / PIXELS_PER_CLOCK) * height; + max_cycles = total_beats * 40 + 10000; + + cfg_img_width = width[15:0]; + cfg_img_height = height[15:0]; + cfg_pattern = pat[3:0]; + cfg_enable = 1'b0; + m_tready = 1'b1; + + // reset + aresetn = 1'b0; + repeat (10) @(posedge aclk); + aresetn = 1'b1; + repeat (5) @(posedge aclk); + cfg_enable = 1'b1; // internal free-run sync + + out_fd = $fopen(out_path, "w"); + if (out_fd == 0) begin + $display("ERROR: cannot open %0s", out_path); + $finish; + end + + count = 0; + started = 0; + i = 0; + n_tuser = 0; + n_tlast = 0; + bad_flag = 0; + while (i < max_cycles && count < total_beats) begin + @(posedge aclk); + #1; + if (m_tvalid && m_tready) begin + if (!started) begin + if (m_tuser) started = 1; + end + if (started) begin + $fdisplay(out_fd, "%0h", m_tdata); + // Structural flag checks: tuser only on the first beat of + // the frame; tlast only on the last beat of each line + // (beat index congruent to beats_per_line-1). + if (m_tuser) n_tuser = n_tuser + 1; + if (m_tuser && count != 0) bad_flag = 1; + if (m_tlast) n_tlast = n_tlast + 1; + if (m_tlast != ((count % (width / PIXELS_PER_CLOCK)) + == (width / PIXELS_PER_CLOCK) - 1)) + bad_flag = 1; + count = count + 1; + end + end + i = i + 1; + end + if (n_tuser != 1 || n_tlast != height || bad_flag) + $display("ERROR: flag check failed tuser=%0d(exp 1) tlast=%0d(exp %0d) bad=%0d", + n_tuser, n_tlast, height, bad_flag); + $fclose(out_fd); + if (count != total_beats) + $display("ERROR: captured %0d of %0d beats", count, total_beats); + else if (n_tuser != 1 || n_tlast != height || bad_flag) + $display("ERROR: flag check failed"); + else + $display("OK: pat=%0d %0dx%0d ppc=%0d -> %0d beats", + pat, width, height, PIXELS_PER_CLOCK, count); + $finish; + end +endmodule From 3a1953616b5d6417a42a360bcba2682468fb6086 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Wed, 29 Jul 2026 18:39:32 +0800 Subject: [PATCH 02/13] Remove stale box-image runtime-toggle note from README Drop the BOX_IMG_X_STEP=0 "runtime toggle" paragraph. --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index e010c7c..764e83b 100644 --- a/README.md +++ b/README.md @@ -375,12 +375,6 @@ Default `BOX_IMAGE_W` / `BOX_IMAGE_H` = 32 picks up ~1 BRAM36 with the 64×64 (~3 BRAM36) if you want more detail. The border ring still draws on top, so a small `BOX_BORDER` value frames the embedded image. -**Runtime toggle.** Writing `BOX_IMG_X_STEP = 0` is the sentinel that -falls back to solid `cfg_box_color` — no other valid step is zero, so -the RTL keys off this to mux between the BRAM read and the solid fill. -This lets the host flip image-in-box on/off without re-synth (the -KV260 demo's `i` UART command uses exactly this). - To enable in a build, pass `EN_IMAGE=1` (and override `IMAGE_W`/`IMAGE_H`/`IMAGE_HEX_FILE` if not using the defaults): From 4931bbc124b7ae209001dfd659d20949478c3527 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Wed, 29 Jul 2026 18:47:16 +0800 Subject: [PATCH 03/13] Extend PIXELS_PER_CLOCK to colorbar/gradients/ramp (M2) Add per-lane support at PPC>1 for COLORBAR, HGRAD, VGRAD and RAMP, joining SOLID/GRID/CHECKER and the box overlay. Colorbar uses a bar-index chain plus a mode-aware palette function; hgrad/ramp use accumulator chains; vgrad is constant per beat so it replicates. The elaboration guard now only forbids NOISE and IMAGE/BOX_IMAGE at PPC>1 (M3). PPC=1 unchanged. Verify: iverilog<->model beat-exact across 36 configs x 7 patterns (252 checks), guard rejection, and PPC=1 regressions. Bump core to 0.3.0. --- README.md | 24 +++--- rtl/vtpgz_core.v | 173 ++++++++++++++++++++++++++------------ rtl/vtpgz_defs.vh | 12 +-- sim/check_ppc_vs_model.py | 24 ++++-- 4 files changed, 155 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 764e83b..98a5419 100644 --- a/README.md +++ b/README.md @@ -287,18 +287,18 @@ rtl/vtpgz_axilite_top.v — thin wrapper that adds an AXI4-Lite slave on | `PIX_TDATA_WIDTH` | (auto) | **Derived**: per-*pixel* packed width — smallest multiple-of-8 that holds the active components. Don't override. | | `C_AXIS_TDATA_WIDTH` | (auto) | **Derived**: full beat width = `PIXELS_PER_CLOCK × PIX_TDATA_WIDTH`. Don't override unless you really know what you're doing | -**Multi-pixel-per-clock (`PIXELS_PER_CLOCK` > 1) — current support (M1)**: -At `PIXELS_PER_CLOCK` of 2/4/8 the build is restricted to the -position-combinational patterns — **SOLID, GRID, CHECKER**, plus the -**moving-box overlay** (fill + border). These produce byte-exact output at -any PPC. The accumulator / counter / stateful patterns (**COLORBAR, HGRAD, -VGRAD, RAMP, NOISE, IMAGE, BOX_IMAGE**) still require `PIXELS_PER_CLOCK=1`; -enabling any of their `EN_*` in a PPC>1 build **fails elaboration** with a -named error module rather than emitting wrong pixels. All output modes -(RGB / RAW-Bayer / YUV 4:4:4 / YUV 4:2:2) and all bit depths are supported -at every PPC. The `PIXELS_PER_CLOCK` value is mirrored read-only at register -offset `0x30` so software can discover it. Widening the remaining patterns -to PPC>1 is planned follow-on work (M2/M3). +**Multi-pixel-per-clock (`PIXELS_PER_CLOCK` > 1) — current support**: +At `PIXELS_PER_CLOCK` of 2/4/8 the following patterns produce beat-exact +output: **SOLID, GRID, CHECKER, COLORBAR, HGRAD, VGRAD, RAMP**, plus the +**moving-box overlay** (fill + border). Only the sequential-LFSR **NOISE** +and the BRAM-backed **IMAGE / BOX_IMAGE** patterns still require +`PIXELS_PER_CLOCK=1`; enabling any of `EN_NOISE`, `EN_IMAGE`, or +`EN_BOX_IMAGE` in a PPC>1 build **fails elaboration** with a named error +module rather than emitting wrong pixels. All output modes (RGB / RAW-Bayer / +YUV 4:4:4 / YUV 4:2:2) and all bit depths are supported at every PPC. The +`PIXELS_PER_CLOCK` value is mirrored read-only at register offset `0x30` so +software can discover it. Widening NOISE and the image patterns to PPC>1 is +planned follow-on work (M3). **Output mode notes**: - `OUTPUT_MODE=0` (RGB) outputs 3-component RGB packed as diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index 93fcb32..82893fa 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -171,9 +171,10 @@ module vtpgz_core #( VTPGZ_PIXELS_PER_CLOCK_MUST_BE_1_2_4_OR_8 guard(); end if (NPPC != 1) begin : g_ppc_guard - if (EN_COLORBAR || EN_HGRAD || EN_VGRAD || EN_RAMP || - EN_NOISE || EN_IMAGE || EN_BOX_IMAGE) begin : g_unsupported - VTPGZ_PPC_GT1_SUPPORTS_ONLY_SOLID_GRID_CHECKER_BOX guard(); + // M2 lifts colorbar/hgrad/vgrad/ramp; noise (sequential LFSR) and + // the BRAM-backed image patterns remain PPC=1-only (M3). + if (EN_NOISE || EN_IMAGE || EN_BOX_IMAGE) begin : g_unsupported + VTPGZ_PPC_GT1_DOES_NOT_YET_SUPPORT_NOISE_OR_IMAGE guard(); end end endgenerate @@ -396,73 +397,95 @@ module vtpgz_core #( // per-pixel path would produce at (x+l, y) and only exist for NPPC>1. wire [12*NPPC-1:0] chk_v_bus; wire [12*NPPC-1:0] grid_r_bus, grid_g_bus, grid_b_bus; + wire [12*NPPC-1:0] cb_r_bus, cb_g_bus, cb_b_bus; // colorbar palette triple + wire [12*NPPC-1:0] hg_bus, vg_bus, ramp_bus; // gray-value patterns // ---- Color bars (8 SMPTE bars) ---- // Counter-based: increment bar index every cfg_bar_width pixels. // Host writes BAR_WIDTH = img_width/8 once per resolution change. + // + // Mode-aware palette as a function so each lane can look up its own bar + // index. In RGB/RAW the triple is {R,G,B}; in YUV it is {Y,Cb,Cr}. + // Returns {c0[12], c1[12], c2[12]}. Constants only -- no DSPs. + function [35:0] bar_palette; + input [2:0] idx; + begin + if (OUTPUT_MODE == `VTPGZ_MODE_YUV) begin + case (idx) + 3'd0: bar_palette = {12'hFFF, 12'h800, 12'h800}; // white + 3'd1: bar_palette = {12'hE2C, 12'h000, 12'h94D}; // yellow + 3'd2: bar_palette = {12'hB37, 12'hAB3, 12'h000}; // cyan + 3'd3: bar_palette = {12'h964, 12'h2B4, 12'h14E}; // green + 3'd4: bar_palette = {12'h69B, 12'hD4C, 12'hEB2}; // magenta + 3'd5: bar_palette = {12'h4C8, 12'h54D, 12'hFFF}; // red + 3'd6: bar_palette = {12'h1D3, 12'hFFF, 12'h6B3}; // blue + default: bar_palette = {12'h000, 12'h800, 12'h800}; // black + endcase + end else begin + case (idx) + 3'd0: bar_palette = {12'hFFF, 12'hFFF, 12'hFFF}; + 3'd1: bar_palette = {12'hFFF, 12'hFFF, 12'h000}; + 3'd2: bar_palette = {12'h000, 12'hFFF, 12'hFFF}; + 3'd3: bar_palette = {12'h000, 12'hFFF, 12'h000}; + 3'd4: bar_palette = {12'hFFF, 12'h000, 12'hFFF}; + 3'd5: bar_palette = {12'hFFF, 12'h000, 12'h000}; + 3'd6: bar_palette = {12'h000, 12'h000, 12'hFFF}; + default: bar_palette = {12'h000, 12'h000, 12'h000}; + endcase + end + end + endfunction + generate if (EN_COLORBAR) begin : g_colorbar reg [15:0] bar_pix_cnt; reg [2:0] bar_idx; + // Per-lane (bar_pix_cnt, bar_idx) chain: same single-step recurrence + // as the base counter, one step per lane. bix_l[gl] is lane gl's bar + // index; the base advances by NPPC steps per beat. At NPPC==1 this is + // one step and reproduces the original recurrence exactly. + wire [15:0] bpc_l [0:NPPC]; + wire [2:0] bix_l [0:NPPC]; + assign bpc_l[0] = bar_pix_cnt; + assign bix_l[0] = bar_idx; + genvar cbl; + for (cbl = 0; cbl < NPPC; cbl = cbl + 1) begin : g_cb_chain + wire wrap_l = (bpc_l[cbl] + 16'h1 >= bar_width_eff); + assign bpc_l[cbl+1] = wrap_l ? 16'h0 : (bpc_l[cbl] + 16'h1); + assign bix_l[cbl+1] = wrap_l ? (bix_l[cbl] + 3'h1) : bix_l[cbl]; + wire [35:0] pal_l = bar_palette(bix_l[cbl]); + assign cb_r_bus[12*cbl +: 12] = pal_l[35:24]; + assign cb_g_bus[12*cbl +: 12] = pal_l[23:12]; + assign cb_b_bus[12*cbl +: 12] = pal_l[11:0]; + end always @(posedge aclk) begin if (!aresetn || frame_init) begin bar_pix_cnt <= 16'h0; bar_idx <= 3'h0; end else if (source_advance) begin + // Force the bar walk back to bar 0 at the END of each line so + // the next line's pixel 0 reads bar 0 combinationally (do not + // also reset on pix_sof -- frame_init already clears the first + // active pixel; resetting at x=0 would shift every transition + // one pixel late). Otherwise advance the base by NPPC pixels. if (last_x) begin - // Force the bar walk back to bar 0 at the END - // of each line so the next line's pixel 0 reads bar 0 - // combinationally. Do not also reset on pix_sof: - // frame_init already clears the state before the first - // active pixel, and resetting again at x=0 would skip - // counting that beat, shifting every bar transition one - // pixel late. bar_pix_cnt <= 16'h0; bar_idx <= 3'h0; - end else if (bar_pix_cnt + 16'h1 >= bar_width_eff) begin - bar_pix_cnt <= 16'h0; - bar_idx <= bar_idx + 3'h1; end else begin - bar_pix_cnt <= bar_pix_cnt + 16'h1; + bar_pix_cnt <= bpc_l[NPPC]; + bar_idx <= bix_l[NPPC]; end end end - // Mode-aware palette: in RGB/RAW the triple is {R,G,B}; in YUV the - // triple is {Y,Cb,Cr}. Constants only -- no DSPs needed. - reg [11:0] cb_c0_r, cb_c1_r, cb_c2_r; - if (OUTPUT_MODE == `VTPGZ_MODE_YUV) begin : g_yuv_pal - always @* begin - case (bar_idx) - 3'd0: begin cb_c0_r=12'hFFF; cb_c1_r=12'h800; cb_c2_r=12'h800; end // white - 3'd1: begin cb_c0_r=12'hE2C; cb_c1_r=12'h000; cb_c2_r=12'h94D; end // yellow - 3'd2: begin cb_c0_r=12'hB37; cb_c1_r=12'hAB3; cb_c2_r=12'h000; end // cyan - 3'd3: begin cb_c0_r=12'h964; cb_c1_r=12'h2B4; cb_c2_r=12'h14E; end // green - 3'd4: begin cb_c0_r=12'h69B; cb_c1_r=12'hD4C; cb_c2_r=12'hEB2; end // magenta - 3'd5: begin cb_c0_r=12'h4C8; cb_c1_r=12'h54D; cb_c2_r=12'hFFF; end // red - 3'd6: begin cb_c0_r=12'h1D3; cb_c1_r=12'hFFF; cb_c2_r=12'h6B3; end // blue - default:begin cb_c0_r=12'h000; cb_c1_r=12'h800; cb_c2_r=12'h800; end // black - endcase - end - end else begin : g_rgb_pal - always @* begin - case (bar_idx) - 3'd0: begin cb_c0_r=12'hFFF; cb_c1_r=12'hFFF; cb_c2_r=12'hFFF; end - 3'd1: begin cb_c0_r=12'hFFF; cb_c1_r=12'hFFF; cb_c2_r=12'h000; end - 3'd2: begin cb_c0_r=12'h000; cb_c1_r=12'hFFF; cb_c2_r=12'hFFF; end - 3'd3: begin cb_c0_r=12'h000; cb_c1_r=12'hFFF; cb_c2_r=12'h000; end - 3'd4: begin cb_c0_r=12'hFFF; cb_c1_r=12'h000; cb_c2_r=12'hFFF; end - 3'd5: begin cb_c0_r=12'hFFF; cb_c1_r=12'h000; cb_c2_r=12'h000; end - 3'd6: begin cb_c0_r=12'h000; cb_c1_r=12'h000; cb_c2_r=12'hFFF; end - default:begin cb_c0_r=12'h000; cb_c1_r=12'h000; cb_c2_r=12'h000; end - endcase - end - end - assign cb_r = cb_c0_r; - assign cb_g = cb_c1_r; - assign cb_b = cb_c2_r; + assign cb_r = cb_r_bus[11:0]; + assign cb_g = cb_g_bus[11:0]; + assign cb_b = cb_b_bus[11:0]; end else begin : g_colorbar_off assign cb_r = 12'h000; assign cb_g = 12'h000; assign cb_b = 12'h000; + assign cb_r_bus = {(12*NPPC){1'b0}}; + assign cb_g_bus = {(12*NPPC){1'b0}}; + assign cb_b_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- Horizontal gradient ---- @@ -471,6 +494,18 @@ module vtpgz_core #( // ~ 0xFFF / width, written once per resolution change. generate if (EN_HGRAD) begin : g_hgrad reg [19:0] hg_acc; // 4 int + 12 frac of headroom + 4 guard + // Per-lane accumulator chain: lane gl sees hg_acc + gl*step. The base + // advances by NPPC*step per beat; at NPPC==1 this is one +step and is + // identical to the original. + wire [19:0] hga_l [0:NPPC]; + assign hga_l[0] = hg_acc; + genvar hgl; + for (hgl = 0; hgl < NPPC; hgl = hgl + 1) begin : g_hg_chain + assign hga_l[hgl+1] = hga_l[hgl] + {4'h0, cfg_hg_step}; + // Saturate each lane to 12 bits. + assign hg_bus[12*hgl +: 12] = + (|hga_l[hgl][19:12]) ? 12'hFFF : hga_l[hgl][11:0]; + end always @(posedge aclk) begin if (!aresetn || frame_init) hg_acc <= 20'h0; else if (source_advance) begin @@ -481,13 +516,14 @@ module vtpgz_core #( // from the previous line and producing a bright artifact // at col 0 of every row. if (last_x) hg_acc <= 20'h0; - else hg_acc <= hg_acc + {4'h0, cfg_hg_step}; + else hg_acc <= hga_l[NPPC]; end end - // Saturate to 12 bits - assign hg_val = (|hg_acc[19:12]) ? 12'hFFF : hg_acc[11:0]; + // Saturate to 12 bits (lane 0) + assign hg_val = hg_bus[11:0]; end else begin : g_hgrad_off assign hg_val = 12'h000; + assign hg_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- Vertical gradient ---- @@ -501,9 +537,16 @@ module vtpgz_core #( else if (last_x) vg_acc <= vg_acc + {4'h0, cfg_vg_step}; end end + // vg_acc only changes per LINE, so every lane in a beat shares the + // same value -- replicate it across the bus. assign vg_val = (|vg_acc[19:12]) ? 12'hFFF : vg_acc[11:0]; + genvar vgl; + for (vgl = 0; vgl < NPPC; vgl = vgl + 1) begin : g_vg_lane + assign vg_bus[12*vgl +: 12] = vg_val; + end end else begin : g_vgrad_off assign vg_val = 12'h000; + assign vg_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- Checkerboard ---- @@ -735,16 +778,26 @@ module vtpgz_core #( // Same accumulator approach as hgrad. Reuses cfg_hg_step. generate if (EN_RAMP) begin : g_ramp reg [19:0] ramp_acc; + // Per-lane accumulator chain (same structure as hgrad). + wire [19:0] rmp_l [0:NPPC]; + assign rmp_l[0] = ramp_acc; + genvar rml; + for (rml = 0; rml < NPPC; rml = rml + 1) begin : g_ramp_chain + assign rmp_l[rml+1] = rmp_l[rml] + {4'h0, cfg_hg_step}; + assign ramp_bus[12*rml +: 12] = + (|rmp_l[rml][19:12]) ? 12'hFFF : rmp_l[rml][11:0]; + end always @(posedge aclk) begin if (!aresetn || frame_init) ramp_acc <= 20'h0; else if (source_advance) begin if (last_x) ramp_acc <= 20'h0; - else ramp_acc <= ramp_acc + {4'h0, cfg_hg_step}; + else ramp_acc <= rmp_l[NPPC]; end end - assign ramp_v = (|ramp_acc[19:12]) ? 12'hFFF : ramp_acc[11:0]; + assign ramp_v = ramp_bus[11:0]; end else begin : g_ramp_off assign ramp_v = 12'h000; + assign ramp_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- Noise (LFSR-16) ---- @@ -1002,16 +1055,30 @@ module vtpgz_core #( wire [12*NPPC-1:0] pat_c0_bus, pat_c1_bus, pat_c2_bus; genvar gpl; generate for (gpl = 0; gpl < NPPC; gpl = gpl + 1) begin : g_pat_lane - wire [11:0] chkl = chk_v_bus[12*gpl +: 12]; + wire [11:0] chkl = chk_v_bus[12*gpl +: 12]; + wire [11:0] hgl = hg_bus [12*gpl +: 12]; + wire [11:0] vgl = vg_bus [12*gpl +: 12]; + wire [11:0] rmpl = ramp_bus[12*gpl +: 12]; + // Gray-to-triple chroma for the build's color space (see the scalar + // hg_c1/hg_c2 helpers): luma in c0, neutral chroma in c1/c2 for YUV. wire [11:0] chkl_c = is_yuv_build ? CHROMA_NEUTRAL : chkl; + wire [11:0] hgl_c = is_yuv_build ? CHROMA_NEUTRAL : hgl; + wire [11:0] vgl_c = is_yuv_build ? CHROMA_NEUTRAL : vgl; + wire [11:0] rmpl_c = is_yuv_build ? CHROMA_NEUTRAL : rmpl; reg [11:0] p0, p1, p2; always @* begin case (cfg_pattern) + `VTPGZ_PAT_COLORBAR: begin p0 = cb_r_bus[12*gpl +: 12]; + p1 = cb_g_bus[12*gpl +: 12]; + p2 = cb_b_bus[12*gpl +: 12]; end + `VTPGZ_PAT_HGRAD : begin p0 = hgl; p1 = hgl_c; p2 = hgl_c; end + `VTPGZ_PAT_VGRAD : begin p0 = vgl; p1 = vgl_c; p2 = vgl_c; end `VTPGZ_PAT_CHECKER : begin p0 = chkl; p1 = chkl_c; p2 = chkl_c; end `VTPGZ_PAT_SOLID : begin p0 = solid_r; p1 = solid_g; p2 = solid_b; end `VTPGZ_PAT_GRID : begin p0 = grid_r_bus[12*gpl +: 12]; p1 = grid_g_bus[12*gpl +: 12]; p2 = grid_b_bus[12*gpl +: 12]; end + `VTPGZ_PAT_RAMP : begin p0 = rmpl; p1 = rmpl_c; p2 = rmpl_c; end default : begin p0 = 12'h0; p1 = 12'h0; p2 = 12'h0; end endcase end diff --git a/rtl/vtpgz_defs.vh b/rtl/vtpgz_defs.vh index 05e78c7..54f5f67 100644 --- a/rtl/vtpgz_defs.vh +++ b/rtl/vtpgz_defs.vh @@ -8,16 +8,18 @@ `define VTPGZ_DEFS_VH // IP version -// 0.2.0 = current: PIXELS_PER_CLOCK build param (1/2/4/8). M1 scope -- -// SOLID/GRID/CHECKER + moving-box overlay supported at PPC>1; -// other patterns require PPC=1 (elaboration-enforced). PPC -// mirrored RO at reg 0x30. PPC=1 netlist unchanged. +// 0.3.0 = current: PIXELS_PER_CLOCK M2 -- COLORBAR/HGRAD/VGRAD/RAMP now +// beat-exact at PPC>1 (join SOLID/GRID/CHECKER + box overlay). +// Only NOISE and IMAGE/BOX_IMAGE remain PPC=1-only (M3). +// 0.2.0: PIXELS_PER_CLOCK build param (1/2/4/8), M1 scope -- +// SOLID/GRID/CHECKER + moving-box overlay at PPC>1; PPC mirrored +// RO at reg 0x30. PPC=1 netlist unchanged. // 0.1.2: configurable inter-line TVALID gap // 0.1.1: colorbar state/AXIS alignment fix + regression // 0.1.0: box overlay + configurable border, no CSC, BPC 8-16, // 4 Bayer tiles, CORE_ID at 0x00, vtpgz_core split `define VTPGZ_VERSION_MAJOR 8'd0 -`define VTPGZ_VERSION_MINOR 8'd2 +`define VTPGZ_VERSION_MINOR 8'd3 `define VTPGZ_VERSION_PATCH 16'd0 // Register byte offsets (AXI4-Lite, 32-bit data) diff --git a/sim/check_ppc_vs_model.py b/sim/check_ppc_vs_model.py index e8270f5..ff8fd17 100644 --- a/sim/check_ppc_vs_model.py +++ b/sim/check_ppc_vs_model.py @@ -2,10 +2,11 @@ # SPDX-FileCopyrightText: 2026 Leonardo Capossio - bard0 design - hello@bard0.com # SPDX-License-Identifier: Apache-2.0 """ -iverilog(RTL) <-> Python-model beat-exact gate for the pixels-per-clock (M1) -feature. Builds tb_ppc_capture.v once per (ppc, mode, bpc, ...) config with -iverilog -P overrides, sweeps the M1 patterns, and compares each captured -frame (one hex beat per line) against render_frame_beats(). +iverilog(RTL) <-> Python-model beat-exact gate for the pixels-per-clock +feature (M1+M2 patterns). Builds tb_ppc_capture.v once per (ppc, mode, bpc, +...) config with iverilog -P overrides, sweeps the PPC-supported patterns, +and compares each captured frame (one hex beat per line) against +render_frame_beats(). Requires iverilog + vvp in PATH. Verilator is NOT needed here (that remains the byte-exact gate for the PPC=1 baseline via sim/run_sim.py). @@ -34,6 +35,7 @@ RAW_PLAIN, RAW_RGGB, RAW_BGGR, RAW_GRBG, RAW_GBRG, RGB_ORDER_XILINX, RGB_ORDER_LEGACY, PAT_SOLID, PAT_GRID, PAT_CHECKER, + PAT_COLORBAR, PAT_HGRAD, PAT_VGRAD, PAT_RAMP, ) MODE_MAP = {"rgb": MODE_RGB, "raw": MODE_RAW, "yuv": MODE_YUV} @@ -42,9 +44,13 @@ "grbg": RAW_GRBG, "gbrg": RAW_GBRG} ORDER_MAP = {"xilinx": RGB_ORDER_XILINX, "legacy": RGB_ORDER_LEGACY} -# Patterns exercised at PPC>1 (M1 scope) and the box-overlay (pattern SOLID -# with the box enabled is covered too, driven by the harness box_* config). -M1_PATTERNS = [("solid", PAT_SOLID), ("grid", PAT_GRID), ("checker", PAT_CHECKER)] +# Patterns exercised at PPC>1 (M1 + M2 scope). The box overlay rides on top +# of every pattern (driven by the harness box_* config), so it is covered too. +PPC_PATTERNS = [ + ("solid", PAT_SOLID), ("grid", PAT_GRID), ("checker", PAT_CHECKER), + ("colorbar", PAT_COLORBAR), ("hgrad", PAT_HGRAD), + ("vgrad", PAT_VGRAD), ("ramp", PAT_RAMP), +] # Must mirror the constant cfg_* the harness drives (tb_ppc_capture.v). HARNESS_CFG = dict( @@ -73,6 +79,8 @@ def build(ppc: int, mode: int, sub: int, bayer: int, order: int, bpc: int, params = { "PIXELS_PER_CLOCK": ppc, "OUTPUT_MODE": mode, "YUV_SUBSAMPLE": sub, "RAW_BAYER": bayer, "RGB_ORDER": order, "BPC": bpc, + # M2 patterns are legal at PPC>1 now; enable them for the sweep. + "EN_COLORBAR": 1, "EN_HGRAD": 1, "EN_VGRAD": 1, "EN_RAMP": 1, } cmd = [iverilog, "-g2001", "-Wall", "-I", str(RTL), "-s", top, "-o", str(out_vvp)] @@ -117,7 +125,7 @@ def check_one(ppc: int, mode_name: str, bpc: int, sub_name: str, vvp_bin = tmp / f"ppc{ppc}_{mode_name}_{bpc}.vvp" build(ppc, mode, sub, bayer, order, bpc, vvp_bin) fails: list[str] = [] - for pname, pat in M1_PATTERNS: + for pname, pat in PPC_PATTERNS: hexf = tmp / f"cap_{ppc}_{mode_name}_{bpc}_{pname}.hex" run_capture(vvp_bin, pat, width, height, hexf) sim = load_beats(hexf) From eb84049ee529632ef6c446d5f4a05c183829264d Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Wed, 29 Jul 2026 19:46:40 +0800 Subject: [PATCH 04/13] Gate per-lane pattern mux behind PPC>1 The per-lane pattern mux (pat_c*_bus) is only consumed by the PPC>1 pipeline lanes, so at PIXELS_PER_CLOCK=1 it was dead logic relying on synthesis DCE. Wrap it in `if (NPPC>1)` and start the lane loop at 1 (lane 0 always comes from the scalar mux), so the 1ppc netlist is provably free of the extra mux and no lane is duplicated at any PPC. --- rtl/vtpgz_core.v | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index 82893fa..dce5e4c 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -1052,9 +1052,19 @@ module vtpgz_core #( // NPPC==1 datapath is untouched. The stateful/accumulator patterns are // elaboration-forbidden at NPPC>1 (g_ppc_guard), so a black default here // is never selected in a legal build. + // Gated on NPPC>1: at NPPC==1 the pipeline latches lane 0 from the scalar + // pat_c0/c1/c2 above and never reads this bus, so building it would be + // dead logic. Explicitly stripping it (rather than leaning on synthesis + // DCE) keeps the 1ppc build provably identical to prior releases. The + // loop also starts at lane 1 -- lane 0 of the bus is always sourced from + // the scalar mux -- so no lane's mux is duplicated at any NPPC. wire [12*NPPC-1:0] pat_c0_bus, pat_c1_bus, pat_c2_bus; genvar gpl; - generate for (gpl = 0; gpl < NPPC; gpl = gpl + 1) begin : g_pat_lane + generate if (NPPC > 1) begin : g_pat_lanes + assign pat_c0_bus[11:0] = 12'h0; // lane 0 unused (scalar path drives it) + assign pat_c1_bus[11:0] = 12'h0; + assign pat_c2_bus[11:0] = 12'h0; + for (gpl = 1; gpl < NPPC; gpl = gpl + 1) begin : g_pat_lane wire [11:0] chkl = chk_v_bus[12*gpl +: 12]; wire [11:0] hgl = hg_bus [12*gpl +: 12]; wire [11:0] vgl = vg_bus [12*gpl +: 12]; @@ -1085,6 +1095,11 @@ module vtpgz_core #( assign pat_c0_bus[12*gpl +: 12] = p0; assign pat_c1_bus[12*gpl +: 12] = p1; assign pat_c2_bus[12*gpl +: 12] = p2; + end + end else begin : g_pat_lanes_off + assign pat_c0_bus = 12'h0; + assign pat_c1_bus = 12'h0; + assign pat_c2_bus = 12'h0; end endgenerate // ---- Box overlay (post-mux) ---- From 09ab9ad6fcfc31260d34b5fd6f78a952a63ab1b6 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Wed, 29 Jul 2026 20:42:56 +0800 Subject: [PATCH 05/13] Complete PIXELS_PER_CLOCK for all patterns (M3) Add per-lane support at PPC>1 for the last patterns: NOISE, IMAGE and the BOX_IMAGE overlay. NOISE uses a leap-ahead LFSR (feedback unrolled NPPC times so lanes get consecutive states, base jumps one beat). IMAGE and BOX_IMAGE replicate their source memory per lane with a combinational, shift-free read (matches the reference model); the PPC=1 path keeps its registered BRAM read and legacy 1-px shift, untouched. The elaboration guard now only rejects an illegal PPC value -- no per-pattern restriction. Verify: iverilog<->model beat-exact, 321 pattern-checks across ppc {1,2,4,8} x mode {rgb,raw,yuv} x bpc {8,12,16}, IMAGE with 2x scaling and a dedicated box-image overlay check. Smoke passes; bad-PPC guard fires. Bump core to 0.4.0. --- README.md | 32 ++-- rtl/vtpgz_core.v | 357 ++++++++++++++++++++++++-------------- rtl/vtpgz_defs.vh | 7 +- sim/check_ppc_vs_model.py | 119 ++++++++++++- sim/tb_ppc_capture.v | 30 +++- 5 files changed, 388 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 98a5419..4019bea 100644 --- a/README.md +++ b/README.md @@ -287,18 +287,26 @@ rtl/vtpgz_axilite_top.v — thin wrapper that adds an AXI4-Lite slave on | `PIX_TDATA_WIDTH` | (auto) | **Derived**: per-*pixel* packed width — smallest multiple-of-8 that holds the active components. Don't override. | | `C_AXIS_TDATA_WIDTH` | (auto) | **Derived**: full beat width = `PIXELS_PER_CLOCK × PIX_TDATA_WIDTH`. Don't override unless you really know what you're doing | -**Multi-pixel-per-clock (`PIXELS_PER_CLOCK` > 1) — current support**: -At `PIXELS_PER_CLOCK` of 2/4/8 the following patterns produce beat-exact -output: **SOLID, GRID, CHECKER, COLORBAR, HGRAD, VGRAD, RAMP**, plus the -**moving-box overlay** (fill + border). Only the sequential-LFSR **NOISE** -and the BRAM-backed **IMAGE / BOX_IMAGE** patterns still require -`PIXELS_PER_CLOCK=1`; enabling any of `EN_NOISE`, `EN_IMAGE`, or -`EN_BOX_IMAGE` in a PPC>1 build **fails elaboration** with a named error -module rather than emitting wrong pixels. All output modes (RGB / RAW-Bayer / -YUV 4:4:4 / YUV 4:2:2) and all bit depths are supported at every PPC. The -`PIXELS_PER_CLOCK` value is mirrored read-only at register offset `0x30` so -software can discover it. Widening NOISE and the image patterns to PPC>1 is -planned follow-on work (M3). +**Multi-pixel-per-clock (`PIXELS_PER_CLOCK` > 1)**: +At `PIXELS_PER_CLOCK` of 2/4/8, **every** pattern produces beat-exact output +— SOLID, GRID, CHECKER, COLORBAR, HGRAD, VGRAD, RAMP, NOISE, IMAGE — plus the +moving-box overlay (fill, border, and box-image). All output modes (RGB / +RAW-Bayer / YUV 4:4:4 / YUV 4:2:2) and all bit depths are supported at every +PPC. `IMG_WIDTH` is clamped down to a multiple of `PIXELS_PER_CLOCK`. Only an +illegal `PIXELS_PER_CLOCK` (not 1/2/4/8) fails elaboration. The value is +mirrored read-only at register offset `0x30` so software can discover it. + +Implementation notes: +- 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 (the feedback is unrolled `PIXELS_PER_CLOCK` + times so each lane gets consecutive states and the base register jumps + ahead one beat). +- IMAGE / BOX_IMAGE replicate their source memory once per lane and read it + combinationally, so keep `IMAGE_W`/`IMAGE_H` (and `BOX_IMAGE_W/H`) modest at + high PPC — total image storage scales with `PIXELS_PER_CLOCK`. The `PPC>1` + image read is shift-free (matches the reference model); the `PPC=1` path + keeps its registered BRAM read and the pre-existing uniform 1-px shift. **Output mode notes**: - `OUTPUT_MODE=0` (RGB) outputs 3-component RGB packed as diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index dce5e4c..98117b4 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -162,21 +162,13 @@ module vtpgz_core #( localparam integer NPPC = PIXELS_PER_CLOCK; // ---------------- elaboration guards ---------------- - // PIXELS_PER_CLOCK must be one of 1/2/4/8, and (M1 scope) a PPC>1 build - // may only enable the position-combinational patterns. Any violation - // instantiates an undefined module so elaboration fails loudly with the - // offending name, rather than silently producing wrong pixels. + // PIXELS_PER_CLOCK must be one of 1/2/4/8. As of M3 every pattern (and + // the box + box-image overlays) is supported at PPC>1, so there is no + // longer a per-pattern restriction -- only the legal-value check remains. generate if (!(NPPC == 1 || NPPC == 2 || NPPC == 4 || NPPC == 8)) begin : g_ppc_bad VTPGZ_PIXELS_PER_CLOCK_MUST_BE_1_2_4_OR_8 guard(); end - if (NPPC != 1) begin : g_ppc_guard - // M2 lifts colorbar/hgrad/vgrad/ramp; noise (sequential LFSR) and - // the BRAM-backed image patterns remain PPC=1-only (M3). - if (EN_NOISE || EN_IMAGE || EN_BOX_IMAGE) begin : g_unsupported - VTPGZ_PPC_GT1_DOES_NOT_YET_SUPPORT_NOISE_OR_IMAGE guard(); - end - end endgenerate // Bit-shrink/grow shift amounts (constant): mirror the BPC pack stage. @@ -399,6 +391,9 @@ module vtpgz_core #( wire [12*NPPC-1:0] grid_r_bus, grid_g_bus, grid_b_bus; wire [12*NPPC-1:0] cb_r_bus, cb_g_bus, cb_b_bus; // colorbar palette triple wire [12*NPPC-1:0] hg_bus, vg_bus, ramp_bus; // gray-value patterns + wire [12*NPPC-1:0] noise_bus; // per-lane LFSR value + wire [12*NPPC-1:0] image_r_bus, image_g_bus, image_b_bus; // per-lane IMAGE + wire [12*NPPC-1:0] box_img_r_bus, box_img_g_bus, box_img_b_bus; // per-lane box-image // ---- Color bars (8 SMPTE bars) ---- // Counter-based: increment bar index every cfg_bar_width pixels. @@ -801,18 +796,30 @@ module vtpgz_core #( end endgenerate // ---- Noise (LFSR-16) ---- + // The LFSR advances one step per pixel and runs continuously across a + // frame (re-seeded only on frame_init, NOT reset per line). For NPPC>1 + // each lane needs the state N consecutive steps apart, so we leap-ahead + // by unrolling the single-step feedback NPPC times combinationally: lane + // gl reads lf_l[gl], and the base register jumps to lf_l[NPPC] each beat. + // At NPPC==1 this is a single step -- identical to the original. generate if (EN_NOISE) begin : g_noise reg [15:0] lfsr; - wire lfsr_fb = lfsr[15] ^ lfsr[13] ^ lfsr[12] ^ lfsr[10]; - // LFSR is re-seeded on frame_init so each fresh enable starts with - // the same state, matching the Python model. + wire [15:0] lf_l [0:NPPC]; + assign lf_l[0] = lfsr; + genvar nl; + for (nl = 0; nl < NPPC; nl = nl + 1) begin : g_noise_chain + wire fb_l = lf_l[nl][15] ^ lf_l[nl][13] ^ lf_l[nl][12] ^ lf_l[nl][10]; + assign lf_l[nl+1] = {lf_l[nl][14:0], fb_l}; + assign noise_bus[12*nl +: 12] = lf_l[nl][11:0]; + end always @(posedge aclk) begin if (!aresetn || frame_init) lfsr <= 16'hACE1; - else if (source_advance) lfsr <= {lfsr[14:0], lfsr_fb}; + else if (source_advance) lfsr <= lf_l[NPPC]; end - assign noise_v = lfsr[11:0]; + assign noise_v = noise_bus[11:0]; end else begin : g_noise_off - assign noise_v = 12'h000; + assign noise_v = 12'h000; + assign noise_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- IMAGE (BRAM-baked synth-time image, with optional nearest- @@ -838,42 +845,18 @@ module vtpgz_core #( localparam ACC_X_W = IMG_LOG2W + FRAC_BITS; localparam ACC_Y_W = IMG_LOG2H + FRAC_BITS; - (* ram_style = "block" *) - reg [23:0] image_mem [0:IMG_DEPTH-1]; - initial begin - $readmemh(IMAGE_HEX_FILE, image_mem); - end - // Centred window offsets. Clamped to 0 if the output window is // wider/taller than the active region (image truncated, no error). wire [15:0] img_x_off = (img_width_eff > IMAGE_OUT_W) ? ((img_width_eff - IMAGE_OUT_W) >> 1) : 16'h0; wire [15:0] img_y_off = (img_height_eff > IMAGE_OUT_H) ? ((img_height_eff - IMAGE_OUT_H) >> 1) : 16'h0; - - wire in_image = (x >= img_x_off) && (x < img_x_off + IMAGE_OUT_W) && - (y >= img_y_off) && (y < img_y_off + IMAGE_OUT_H); wire in_image_y = (y >= img_y_off) && (y < img_y_off + IMAGE_OUT_H); - // X accumulator: reset at the end of each line so the next line - // starts at acc_x=0; increment by IMG_X_STEP each cycle while - // inside the image-x window. Outside, the accumulator holds (its - // value is muxed to black anyway). - reg [ACC_X_W-1:0] acc_x; - always @(posedge aclk) begin - if (!aresetn || frame_init) acc_x <= {ACC_X_W{1'b0}}; - else if (source_advance) begin - if (last_x) acc_x <= {ACC_X_W{1'b0}}; - else if (in_image) acc_x <= acc_x + IMG_X_STEP[ACC_X_W-1:0]; - end - end - - // Y accumulator: step at end of each line that lies in the image-y - // window. Anchor the reset on end_of_frame (one cycle BEFORE the - // next frame's pix_sof) so cycle (x=0, y=0) reads acc_y=0 - // combinationally -- resetting on pix_sof would land one cycle - // late via the NBA and leak the previous frame's last src_y into - // the first output pixel. + // Y accumulator (shared across lanes -- y is common to a beat). Step + // at end of each line that lies in the image-y window. Anchor the + // reset on end_of_frame (one cycle BEFORE the next frame's pix_sof) + // so cycle (x=0, y=0) reads acc_y=0 combinationally. reg [ACC_Y_W-1:0] acc_y; always @(posedge aclk) begin if (!aresetn || frame_init) acc_y <= {ACC_Y_W{1'b0}}; @@ -882,37 +865,98 @@ module vtpgz_core #( else if (last_x && in_image_y) acc_y <= acc_y + IMG_Y_STEP[ACC_Y_W-1:0]; end end - - wire [IMG_LOG2W-1:0] src_x = acc_x[ACC_X_W-1 -: IMG_LOG2W]; wire [IMG_LOG2H-1:0] src_y = acc_y[ACC_Y_W-1 -: IMG_LOG2H]; - wire [IMG_ADDR_W-1:0] image_addr = {src_y, src_x}; - - // Synchronous (block-RAM) read + matched in_image delay. Registering - // the read lets Vivado map image_word_q into the BRAM tile's - // built-in DOUT register, breaking the long combinational - // acc_x -> image_mem -> pattern-mux path so the IMAGE pattern closes - // timing well above the KV260 ~74 MHz DP rate (e.g. 130 MHz). Costs - // one pixel of image latency: a uniform 1-px horizontal shift, - // visually imperceptible at any realistic IMAGE_OUT_W:IMAGE_W ratio. - reg [23:0] image_word_q; - reg in_image_q; - always @(posedge aclk) begin - image_word_q <= image_mem[image_addr]; - in_image_q <= in_image; + if (NPPC == 1) begin : g_img_ppc1 + // ---- NPPC==1: original single-BRAM registered-read path ---- + // Synchronous read maps into the BRAM DOUT register for timing; + // costs a uniform 1-px horizontal shift (unchanged from before). + (* ram_style = "block" *) + reg [23:0] image_mem [0:IMG_DEPTH-1]; + initial begin + $readmemh(IMAGE_HEX_FILE, image_mem); + end + wire in_image = in_image_y && + (x >= img_x_off) && (x < img_x_off + IMAGE_OUT_W); + reg [ACC_X_W-1:0] acc_x; + always @(posedge aclk) begin + if (!aresetn || frame_init) acc_x <= {ACC_X_W{1'b0}}; + else if (source_advance) begin + if (last_x) acc_x <= {ACC_X_W{1'b0}}; + else if (in_image) acc_x <= acc_x + IMG_X_STEP[ACC_X_W-1:0]; + end + end + wire [IMG_LOG2W-1:0] src_x = acc_x[ACC_X_W-1 -: IMG_LOG2W]; + wire [IMG_ADDR_W-1:0] image_addr = {src_y, src_x}; + reg [23:0] image_word_q; + reg in_image_q; + always @(posedge aclk) begin + image_word_q <= image_mem[image_addr]; + in_image_q <= in_image; + end + wire [7:0] img_r8 = image_word_q[23:16]; + wire [7:0] img_g8 = image_word_q[15:8]; + wire [7:0] img_b8 = image_word_q[7:0]; + assign image_r = in_image_q ? {img_r8, img_r8[7:4]} : 12'h000; + assign image_g = in_image_q ? {img_g8, img_g8[7:4]} : 12'h000; + assign image_b = in_image_q ? {img_b8, img_b8[7:4]} : 12'h000; + assign image_r_bus = image_r; // NPPC==1: bus == lane 0 + assign image_g_bus = image_g; + assign image_b_bus = image_b; + end else begin : g_img_ppcN + // ---- NPPC>1: N replicated memories, combinational read ---- + // Each lane reads its own copy at column (x+lane), so the image + // is shift-free and matches the reference model exactly (the + // NPPC==1 registered read's 1-px shift does not apply). Cost is + // N image copies -- keep IMAGE_W/H modest for high PPC. + reg [ACC_X_W-1:0] acc_x; // base = lane 0 acc at pixel x + wire [ACC_X_W-1:0] axc [0:NPPC]; + assign axc[0] = acc_x; + genvar il; + for (il = 0; il < NPPC; il = il + 1) begin : g_img_lane + wire [15:0] xl = x + il[15:0]; + wire in_img_l = in_image_y && + (xl >= img_x_off) && (xl < img_x_off + IMAGE_OUT_W); + // Advance the accumulator only across in-window columns, so + // lane gl sees acc = (col - off)*step, i.e. src = model's + // ((X-off)*step>>16) & (IMAGE_W-1). + assign axc[il+1] = in_img_l ? (axc[il] + IMG_X_STEP[ACC_X_W-1:0]) + : axc[il]; + wire [IMG_LOG2W-1:0] sx_l = axc[il][ACC_X_W-1 -: IMG_LOG2W]; + wire [IMG_ADDR_W-1:0] addr_l = {src_y, sx_l}; + (* ram_style = "block" *) + reg [23:0] mem_l [0:IMG_DEPTH-1]; + initial begin + $readmemh(IMAGE_HEX_FILE, mem_l); + end + reg [23:0] word_l; + always @* word_l = mem_l[addr_l]; + wire [7:0] r8 = word_l[23:16]; + wire [7:0] g8 = word_l[15:8]; + wire [7:0] b8 = word_l[7:0]; + assign image_r_bus[12*il +: 12] = in_img_l ? {r8, r8[7:4]} : 12'h000; + assign image_g_bus[12*il +: 12] = in_img_l ? {g8, g8[7:4]} : 12'h000; + assign image_b_bus[12*il +: 12] = in_img_l ? {b8, b8[7:4]} : 12'h000; + end + always @(posedge aclk) begin + if (!aresetn || frame_init) acc_x <= {ACC_X_W{1'b0}}; + else if (source_advance) begin + if (last_x) acc_x <= {ACC_X_W{1'b0}}; + else acc_x <= axc[NPPC]; + end + end + assign image_r = image_r_bus[11:0]; + assign image_g = image_g_bus[11:0]; + assign image_b = image_b_bus[11:0]; end - - wire [7:0] img_r8 = image_word_q[23:16]; - wire [7:0] img_g8 = image_word_q[15:8]; - wire [7:0] img_b8 = image_word_q[7:0]; - assign image_r = in_image_q ? {img_r8, img_r8[7:4]} : 12'h000; - assign image_g = in_image_q ? {img_g8, img_g8[7:4]} : 12'h000; - assign image_b = in_image_q ? {img_b8, img_b8[7:4]} : 12'h000; // verilator coverage_on end else begin : g_image_off assign image_r = 12'h000; assign image_g = 12'h000; assign image_b = 12'h000; + assign image_r_bus = {(12*NPPC){1'b0}}; + assign image_g_bus = {(12*NPPC){1'b0}}; + assign image_b_bus = {(12*NPPC){1'b0}}; end endgenerate // ---- BOX-image overlay ---- @@ -935,35 +979,10 @@ module vtpgz_core #( localparam BIMG_ACC_X_W = BIMG_LOG2W + BIMG_FRAC; localparam BIMG_ACC_Y_W = BIMG_LOG2H + BIMG_FRAC; - (* ram_style = "block" *) - reg [23:0] box_image_mem [0:BIMG_DEPTH-1]; - initial begin - $readmemh(BOX_IMAGE_HEX_FILE, box_image_mem); - end - - // Per-axis "inside box's x/y range" derived from x, y and the - // box position registers in g_box. Need both for proper acc gating. - wire bimg_in_x = (x >= g_box.box_x) && - (x < g_box.box_x + box_width_eff); + // Y accumulator (shared across lanes). Zero on end_of_frame, step at + // last_x of each line inside box-y. wire bimg_in_y = (y >= g_box.box_y) && (y < g_box.box_y + box_height_eff); - - // X accumulator: zero on last_x (so x=0 of next line starts at - // 0), increment while inside box-x. First in-box cycle reads - // acc_x=0 -> src_x=0. Last in-box reads near BOX_IMAGE_W-1. - reg [BIMG_ACC_X_W-1:0] bimg_acc_x; - always @(posedge aclk) begin - if (!aresetn || frame_init) bimg_acc_x <= {BIMG_ACC_X_W{1'b0}}; - else if (source_advance) begin - if (last_x) bimg_acc_x <= {BIMG_ACC_X_W{1'b0}}; - else if (bimg_in_x) bimg_acc_x <= bimg_acc_x + - cfg_box_img_x_step[BIMG_ACC_X_W-1:0]; - end - end - - // Y accumulator: zero on end_of_frame (avoiding the pix_sof NBA - // leak that bit the IMAGE pattern), step at last_x of each line - // that's inside box-y. reg [BIMG_ACC_Y_W-1:0] bimg_acc_y; always @(posedge aclk) begin if (!aresetn || frame_init) bimg_acc_y <= {BIMG_ACC_Y_W{1'b0}}; @@ -973,36 +992,91 @@ module vtpgz_core #( cfg_box_img_y_step[BIMG_ACC_Y_W-1:0]; end end - - wire [BIMG_LOG2W-1:0] bimg_src_x = bimg_acc_x[BIMG_ACC_X_W-1 -: BIMG_LOG2W]; wire [BIMG_LOG2H-1:0] bimg_src_y = bimg_acc_y[BIMG_ACC_Y_W-1 -: BIMG_LOG2H]; - wire [BIMG_ADDR_W-1:0] bimg_addr = {bimg_src_y, bimg_src_x}; - - // Synchronous read for the same timing reason as the IMAGE - // pattern's BRAM (Vivado packs into the BRAM tile's DOUT register; - // breaks the BRAM-output-to-pat_c0_s1 combinational path). The - // existing pre-mux s1 stage still latches downstream, so the box - // image ends up 1 cycle later than the box mask -- a uniform - // 1-px horizontal shift inside the box, which matches the - // shift on the underlying IMAGE pattern so the two stay - // pixel-aligned. - reg [23:0] bimg_word_q; - always @(posedge aclk) begin - bimg_word_q <= box_image_mem[bimg_addr]; + if (NPPC == 1) begin : g_bimg_ppc1 + // ---- NPPC==1: original single-BRAM registered-read path ---- + (* ram_style = "block" *) + reg [23:0] box_image_mem [0:BIMG_DEPTH-1]; + initial begin + $readmemh(BOX_IMAGE_HEX_FILE, box_image_mem); + end + wire bimg_in_x = (x >= g_box.box_x) && + (x < g_box.box_x + box_width_eff); + reg [BIMG_ACC_X_W-1:0] bimg_acc_x; + always @(posedge aclk) begin + if (!aresetn || frame_init) bimg_acc_x <= {BIMG_ACC_X_W{1'b0}}; + else if (source_advance) begin + if (last_x) bimg_acc_x <= {BIMG_ACC_X_W{1'b0}}; + else if (bimg_in_x) bimg_acc_x <= bimg_acc_x + + cfg_box_img_x_step[BIMG_ACC_X_W-1:0]; + end + end + wire [BIMG_LOG2W-1:0] bimg_src_x = bimg_acc_x[BIMG_ACC_X_W-1 -: BIMG_LOG2W]; + wire [BIMG_ADDR_W-1:0] bimg_addr = {bimg_src_y, bimg_src_x}; + // Synchronous read (BRAM DOUT reg) -- costs the same uniform 1-px + // shift as the IMAGE pattern (kept identical to prior releases). + reg [23:0] bimg_word_q; + always @(posedge aclk) begin + bimg_word_q <= box_image_mem[bimg_addr]; + end + wire [7:0] bimg_r8 = bimg_word_q[23:16]; + wire [7:0] bimg_g8 = bimg_word_q[15:8]; + wire [7:0] bimg_b8 = bimg_word_q[7:0]; + assign box_img_r = {bimg_r8, bimg_r8[7:4]}; + assign box_img_g = {bimg_g8, bimg_g8[7:4]}; + assign box_img_b = {bimg_b8, bimg_b8[7:4]}; + assign box_img_r_bus = box_img_r; + assign box_img_g_bus = box_img_g; + assign box_img_b_bus = box_img_b; + end else begin : g_bimg_ppcN + // ---- NPPC>1: N replicated memories, combinational shift-free ---- + reg [BIMG_ACC_X_W-1:0] bimg_acc_x; // base = lane 0 acc at pixel x + wire [BIMG_ACC_X_W-1:0] bxc [0:NPPC]; + assign bxc[0] = bimg_acc_x; + genvar bil; + for (bil = 0; bil < NPPC; bil = bil + 1) begin : g_bimg_lane + wire [15:0] xl = x + bil[15:0]; + wire bin_x_l = (xl >= g_box.box_x) && + (xl < g_box.box_x + box_width_eff); + assign bxc[bil+1] = bin_x_l ? (bxc[bil] + + cfg_box_img_x_step[BIMG_ACC_X_W-1:0]) + : bxc[bil]; + wire [BIMG_LOG2W-1:0] sx_l = bxc[bil][BIMG_ACC_X_W-1 -: BIMG_LOG2W]; + wire [BIMG_ADDR_W-1:0] addr_l = {bimg_src_y, sx_l}; + (* ram_style = "block" *) + reg [23:0] mem_l [0:BIMG_DEPTH-1]; + initial begin + $readmemh(BOX_IMAGE_HEX_FILE, mem_l); + end + reg [23:0] word_l; + always @* word_l = mem_l[addr_l]; + wire [7:0] r8 = word_l[23:16]; + wire [7:0] g8 = word_l[15:8]; + wire [7:0] b8 = word_l[7:0]; + assign box_img_r_bus[12*bil +: 12] = {r8, r8[7:4]}; + assign box_img_g_bus[12*bil +: 12] = {g8, g8[7:4]}; + assign box_img_b_bus[12*bil +: 12] = {b8, b8[7:4]}; + end + always @(posedge aclk) begin + if (!aresetn || frame_init) bimg_acc_x <= {BIMG_ACC_X_W{1'b0}}; + else if (source_advance) begin + if (last_x) bimg_acc_x <= {BIMG_ACC_X_W{1'b0}}; + else bimg_acc_x <= bxc[NPPC]; + end + end + assign box_img_r = box_img_r_bus[11:0]; + assign box_img_g = box_img_g_bus[11:0]; + assign box_img_b = box_img_b_bus[11:0]; end - - wire [7:0] bimg_r8 = bimg_word_q[23:16]; - wire [7:0] bimg_g8 = bimg_word_q[15:8]; - wire [7:0] bimg_b8 = bimg_word_q[7:0]; - assign box_img_r = {bimg_r8, bimg_r8[7:4]}; - assign box_img_g = {bimg_g8, bimg_g8[7:4]}; - assign box_img_b = {bimg_b8, bimg_b8[7:4]}; // verilator coverage_on end else begin : g_box_image_off assign box_img_r = 12'h000; assign box_img_g = 12'h000; assign box_img_b = 12'h000; + assign box_img_r_bus = {(12*NPPC){1'b0}}; + assign box_img_g_bus = {(12*NPPC){1'b0}}; + assign box_img_b_bus = {(12*NPPC){1'b0}}; end endgenerate // ---------------- pattern mux ---------------- @@ -1069,12 +1143,14 @@ module vtpgz_core #( wire [11:0] hgl = hg_bus [12*gpl +: 12]; wire [11:0] vgl = vg_bus [12*gpl +: 12]; wire [11:0] rmpl = ramp_bus[12*gpl +: 12]; + wire [11:0] nzl = noise_bus[12*gpl +: 12]; // Gray-to-triple chroma for the build's color space (see the scalar // hg_c1/hg_c2 helpers): luma in c0, neutral chroma in c1/c2 for YUV. wire [11:0] chkl_c = is_yuv_build ? CHROMA_NEUTRAL : chkl; wire [11:0] hgl_c = is_yuv_build ? CHROMA_NEUTRAL : hgl; wire [11:0] vgl_c = is_yuv_build ? CHROMA_NEUTRAL : vgl; wire [11:0] rmpl_c = is_yuv_build ? CHROMA_NEUTRAL : rmpl; + wire [11:0] nzl_c = is_yuv_build ? CHROMA_NEUTRAL : nzl; reg [11:0] p0, p1, p2; always @* begin case (cfg_pattern) @@ -1089,6 +1165,10 @@ module vtpgz_core #( p1 = grid_g_bus[12*gpl +: 12]; p2 = grid_b_bus[12*gpl +: 12]; end `VTPGZ_PAT_RAMP : begin p0 = rmpl; p1 = rmpl_c; p2 = rmpl_c; end + `VTPGZ_PAT_NOISE : begin p0 = nzl; p1 = nzl_c; p2 = nzl_c; end + `VTPGZ_PAT_IMAGE : begin p0 = image_r_bus[12*gpl +: 12]; + p1 = image_g_bus[12*gpl +: 12]; + p2 = image_b_bus[12*gpl +: 12]; end default : begin p0 = 12'h0; p1 = 12'h0; p2 = 12'h0; end endcase end @@ -1166,9 +1246,8 @@ module vtpgz_core #( // Box-image colours land in their own s1 registers so the mux at this // stage gets a value computed from the SAME cycle's x,y as box_in_s1 // (the combinational box_img_* would otherwise be one cycle ahead). - // Box-image is a NPPC==1-only feature (elaboration-forbidden at NPPC>1), - // so these remain scalar and only feed lane 0's inside-mux. - reg [11:0] box_img_r_s1, box_img_g_s1, box_img_b_s1; + // Per-lane like the pattern buses: lane 0 in [11:0], lanes 1..N-1 above. + reg [12*NPPC-1:0] box_img_r_s1, box_img_g_s1, box_img_b_s1; reg pix_valid_s1, pix_sof_s1, pix_eol_s1, pix_eof_s1; reg pix_x_lsb_s1, pix_y_lsb_s1; wire pipe_advance; @@ -1179,9 +1258,9 @@ module vtpgz_core #( pat_c0_s1 <= {(12*NPPC){1'b0}}; pat_c1_s1 <= {(12*NPPC){1'b0}}; pat_c2_s1 <= {(12*NPPC){1'b0}}; - box_img_r_s1 <= 12'h0; - box_img_g_s1 <= 12'h0; - box_img_b_s1 <= 12'h0; + box_img_r_s1 <= {(12*NPPC){1'b0}}; + box_img_g_s1 <= {(12*NPPC){1'b0}}; + box_img_b_s1 <= {(12*NPPC){1'b0}}; pix_valid_s1 <= 1'b0; pix_sof_s1 <= 1'b0; pix_eol_s1 <= 1'b0; @@ -1195,9 +1274,9 @@ module vtpgz_core #( pat_c0_s1[11:0] <= pat_c0; pat_c1_s1[11:0] <= pat_c1; pat_c2_s1[11:0] <= pat_c2; - box_img_r_s1 <= box_img_r; - box_img_g_s1 <= box_img_g; - box_img_b_s1 <= box_img_b; + box_img_r_s1[11:0] <= box_img_r; + box_img_g_s1[11:0] <= box_img_g; + box_img_b_s1[11:0] <= box_img_b; pix_valid_s1 <= pix_valid; pix_sof_s1 <= pix_sof; pix_eol_s1 <= pix_eol; @@ -1217,12 +1296,18 @@ module vtpgz_core #( pat_c0_s1[12*gs1 +: 12] <= 12'h0; pat_c1_s1[12*gs1 +: 12] <= 12'h0; pat_c2_s1[12*gs1 +: 12] <= 12'h0; + box_img_r_s1[12*gs1 +: 12] <= 12'h0; + box_img_g_s1[12*gs1 +: 12] <= 12'h0; + box_img_b_s1[12*gs1 +: 12] <= 12'h0; end else if (pipe_advance) begin box_in_s1[gs1] <= box_in_bus[gs1]; box_on_border_s1[gs1] <= box_on_border_bus[gs1]; pat_c0_s1[12*gs1 +: 12] <= pat_c0_bus[12*gs1 +: 12]; pat_c1_s1[12*gs1 +: 12] <= pat_c1_bus[12*gs1 +: 12]; pat_c2_s1[12*gs1 +: 12] <= pat_c2_bus[12*gs1 +: 12]; + box_img_r_s1[12*gs1 +: 12] <= box_img_r_bus[12*gs1 +: 12]; + box_img_g_s1[12*gs1 +: 12] <= box_img_g_bus[12*gs1 +: 12]; + box_img_b_s1[12*gs1 +: 12] <= box_img_b_bus[12*gs1 +: 12]; end end end @@ -1235,12 +1320,11 @@ module vtpgz_core #( // border_width > 0) still wins because box_on_border_s1 is checked // first. wire box_image_active = (EN_BOX_IMAGE != 0) && (cfg_box_img_x_step != 32'h0); - // Lane 0's box interior can show the scaled box-image (NPPC==1 only); - // lanes 1..NPPC-1 always show the solid fill (box-image is forbidden at - // NPPC>1, so this is exact, not an approximation). - wire [11:0] box_inside_c0 = box_image_active ? box_img_r_s1 : box_fill_c0; - wire [11:0] box_inside_c1 = box_image_active ? box_img_g_s1 : box_fill_c1; - wire [11:0] box_inside_c2 = box_image_active ? box_img_b_s1 : box_fill_c2; + // Per-lane box interior: the scaled box-image when active, else solid fill. + // Lane 0 keeps the exact NPPC==1 expression (bus == lane 0 there). + wire [11:0] box_inside_c0 = box_image_active ? box_img_r_s1[11:0] : box_fill_c0; + wire [11:0] box_inside_c1 = box_image_active ? box_img_g_s1[11:0] : box_fill_c1; + wire [11:0] box_inside_c2 = box_image_active ? box_img_b_s1[11:0] : box_fill_c2; // Per-lane composited pixel. Lane 0 uses box_inside_c* (with box-image); // at NPPC==1 pix_c*_bus[11:0] is exactly the original pix_c*. @@ -1254,14 +1338,17 @@ module vtpgz_core #( generate genvar gpc; for (gpc = 1; gpc < NPPC; gpc = gpc + 1) begin : g_pix_lane + wire [11:0] insd_c0 = box_image_active ? box_img_r_s1[12*gpc +: 12] : box_fill_c0; + wire [11:0] insd_c1 = box_image_active ? box_img_g_s1[12*gpc +: 12] : box_fill_c1; + wire [11:0] insd_c2 = box_image_active ? box_img_b_s1[12*gpc +: 12] : box_fill_c2; assign pix_c0_bus[12*gpc +: 12] = box_on_border_s1[gpc] ? box_bdr_c0 : - box_in_s1[gpc] ? box_fill_c0 + box_in_s1[gpc] ? insd_c0 : pat_c0_s1[12*gpc +: 12]; assign pix_c1_bus[12*gpc +: 12] = box_on_border_s1[gpc] ? box_bdr_c1 : - box_in_s1[gpc] ? box_fill_c1 + box_in_s1[gpc] ? insd_c1 : pat_c1_s1[12*gpc +: 12]; assign pix_c2_bus[12*gpc +: 12] = box_on_border_s1[gpc] ? box_bdr_c2 : - box_in_s1[gpc] ? box_fill_c2 + box_in_s1[gpc] ? insd_c2 : pat_c2_s1[12*gpc +: 12]; end endgenerate diff --git a/rtl/vtpgz_defs.vh b/rtl/vtpgz_defs.vh index 54f5f67..02baf3c 100644 --- a/rtl/vtpgz_defs.vh +++ b/rtl/vtpgz_defs.vh @@ -8,7 +8,10 @@ `define VTPGZ_DEFS_VH // IP version -// 0.3.0 = current: PIXELS_PER_CLOCK M2 -- COLORBAR/HGRAD/VGRAD/RAMP now +// 0.4.0 = current: PIXELS_PER_CLOCK M3 -- ALL patterns beat-exact at PPC>1. +// NOISE via leap-ahead LFSR; IMAGE/BOX_IMAGE via per-lane replicated +// combinational read. No per-pattern PPC restriction remains. +// 0.3.0: PIXELS_PER_CLOCK M2 -- COLORBAR/HGRAD/VGRAD/RAMP now // beat-exact at PPC>1 (join SOLID/GRID/CHECKER + box overlay). // Only NOISE and IMAGE/BOX_IMAGE remain PPC=1-only (M3). // 0.2.0: PIXELS_PER_CLOCK build param (1/2/4/8), M1 scope -- @@ -19,7 +22,7 @@ // 0.1.0: box overlay + configurable border, no CSC, BPC 8-16, // 4 Bayer tiles, CORE_ID at 0x00, vtpgz_core split `define VTPGZ_VERSION_MAJOR 8'd0 -`define VTPGZ_VERSION_MINOR 8'd3 +`define VTPGZ_VERSION_MINOR 8'd4 `define VTPGZ_VERSION_PATCH 16'd0 // Register byte offsets (AXI4-Lite, 32-bit data) diff --git a/sim/check_ppc_vs_model.py b/sim/check_ppc_vs_model.py index ff8fd17..6487108 100644 --- a/sim/check_ppc_vs_model.py +++ b/sim/check_ppc_vs_model.py @@ -35,9 +35,30 @@ RAW_PLAIN, RAW_RGGB, RAW_BGGR, RAW_GRBG, RAW_GBRG, RGB_ORDER_XILINX, RGB_ORDER_LEGACY, PAT_SOLID, PAT_GRID, PAT_CHECKER, - PAT_COLORBAR, PAT_HGRAD, PAT_VGRAD, PAT_RAMP, + PAT_COLORBAR, PAT_HGRAD, PAT_VGRAD, PAT_RAMP, PAT_NOISE, PAT_IMAGE, ) +# ---- IMAGE test config: a 16x16 source scaled to an 8x8 window (exercises +# the per-lane Q16 scaler at PPC>1). Deterministic RGB888 so the model and +# the $readmemh'd RTL BRAMs hold identical data. +IMG_W_T, IMG_H_T = 16, 16 +IMG_OUT_T = 8 + + +def gen_image() -> list[int]: + data = [] + for iy in range(IMG_H_T): + for ix in range(IMG_W_T): + r = (ix * 16) & 0xFF + g = (iy * 16) & 0xFF + b = ((ix * 7 + iy * 13) * 3) & 0xFF + data.append((r << 16) | (g << 8) | b) + return data + + +IMAGE_DATA = gen_image() +IMAGE_HEX_PATH = "" # set in main() + MODE_MAP = {"rgb": MODE_RGB, "raw": MODE_RAW, "yuv": MODE_YUV} SUB_MAP = {"444": YUV_444, "422": YUV_422} BAYER_MAP = {"plain": RAW_PLAIN, "rggb": RAW_RGGB, "bggr": RAW_BGGR, @@ -49,7 +70,8 @@ PPC_PATTERNS = [ ("solid", PAT_SOLID), ("grid", PAT_GRID), ("checker", PAT_CHECKER), ("colorbar", PAT_COLORBAR), ("hgrad", PAT_HGRAD), - ("vgrad", PAT_VGRAD), ("ramp", PAT_RAMP), + ("vgrad", PAT_VGRAD), ("ramp", PAT_RAMP), ("noise", PAT_NOISE), + ("image", PAT_IMAGE), ] # Must mirror the constant cfg_* the harness drives (tb_ppc_capture.v). @@ -79,13 +101,19 @@ def build(ppc: int, mode: int, sub: int, bayer: int, order: int, bpc: int, params = { "PIXELS_PER_CLOCK": ppc, "OUTPUT_MODE": mode, "YUV_SUBSAMPLE": sub, "RAW_BAYER": bayer, "RGB_ORDER": order, "BPC": bpc, - # M2 patterns are legal at PPC>1 now; enable them for the sweep. + # M2/M3 patterns are legal at PPC>1 now; enable them for the sweep. "EN_COLORBAR": 1, "EN_HGRAD": 1, "EN_VGRAD": 1, "EN_RAMP": 1, + "EN_NOISE": 1, } cmd = [iverilog, "-g2001", "-Wall", "-I", str(RTL), "-s", top, "-o", str(out_vvp)] for k, v in params.items(): cmd += ["-P", f"{top}.{k}={v}"] + # IMAGE pattern: enable the BRAM path with the small scaled test image. + cmd += ["-P", f"{top}.EN_IMAGE=1", + "-P", f"{top}.IMAGE_W={IMG_W_T}", "-P", f"{top}.IMAGE_H={IMG_H_T}", + "-P", f"{top}.IMAGE_OUT_W={IMG_OUT_T}", "-P", f"{top}.IMAGE_OUT_H={IMG_OUT_T}", + "-P", f'{top}.IMAGE_HEX_FILE="{IMAGE_HEX_PATH}"'] cmd += [str(RTL / "vtpgz_core.v"), str(HERE / "tb_ppc_capture.v")] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: @@ -111,6 +139,9 @@ def model_beats(pat: int, ppc: int, mode: int, sub: int, bayer: int, cfg = VtpgzConfig(width=width, height=height, pattern=pat, output_mode=mode, yuv_subsample=sub, raw_bayer=bayer, rgb_order=order, bpc=bpc, pixels_per_clock=ppc, + image_w=IMG_W_T, image_h=IMG_H_T, + image_out_w=IMG_OUT_T, image_out_h=IMG_OUT_T, + image_rgb888=IMAGE_DATA, **HARNESS_CFG) return render_frame_beats(cfg) @@ -125,7 +156,12 @@ def check_one(ppc: int, mode_name: str, bpc: int, sub_name: str, vvp_bin = tmp / f"ppc{ppc}_{mode_name}_{bpc}.vvp" build(ppc, mode, sub, bayer, order, bpc, vvp_bin) fails: list[str] = [] - for pname, pat in PPC_PATTERNS: + # At PPC=1 the IMAGE pattern keeps its registered-read 1-px horizontal + # shift (unchanged from prior releases and never model-gated), so it is + # not beat-comparable to the shift-free model. The PPC>1 path is + # combinational/shift-free and IS compared. Skip image only at PPC=1. + patterns = [p for p in PPC_PATTERNS if not (ppc == 1 and p[0] == "image")] + for pname, pat in patterns: hexf = tmp / f"cap_{ppc}_{mode_name}_{bpc}_{pname}.hex" run_capture(vvp_bin, pat, width, height, hexf) sim = load_beats(hexf) @@ -144,6 +180,69 @@ def check_one(ppc: int, mode_name: str, bpc: int, sub_name: str, return fails +# ---- BOX-IMAGE test: an 8x8 source scaled into the 12x8 moving box. ---- +BIMG_W_T, BIMG_H_T = 8, 8 +BIMG_X_STEP = (BIMG_W_T << 16) // 12 # box_width=12 (HARNESS_CFG) +BIMG_Y_STEP = (BIMG_H_T << 16) // 8 # box_height=8 + + +def gen_box_image() -> list[int]: + data = [] + for iy in range(BIMG_H_T): + for ix in range(BIMG_W_T): + data.append(((ix * 30) << 16) | ((iy * 30) << 8) | ((ix ^ iy) * 20 & 0xFF)) + return data + + +BIMG_DATA = gen_box_image() + + +def check_box_image(tmp: Path) -> list[str]: + """Box-image overlay at PPC>1: build with EN_BOX_IMAGE + non-zero runtime + steps so the box interior shows the scaled image, capture a couple of + patterns, and compare to the model (which applies the same box overlay). + PPC=1 is skipped -- its registered read keeps the legacy 1-px shift.""" + iverilog = need("iverilog") + top = "tb_ppc_capture" + mem = tmp / "ppc_boximg.mem" + mem.write_text("\n".join(f"{w:06x}" for w in BIMG_DATA) + "\n") + fails: list[str] = [] + for ppc in (2, 4, 8): + vvp_bin = tmp / f"boximg_ppc{ppc}.vvp" + cmd = [iverilog, "-g2001", "-Wall", "-I", str(RTL), "-s", top, + "-o", str(vvp_bin), + "-P", f"{top}.PIXELS_PER_CLOCK={ppc}", + "-P", f"{top}.OUTPUT_MODE={MODE_RGB}", "-P", f"{top}.BPC=8", + "-P", f"{top}.EN_BOX_IMAGE=1", + "-P", f"{top}.BOX_IMAGE_W={BIMG_W_T}", "-P", f"{top}.BOX_IMAGE_H={BIMG_H_T}", + "-P", f'{top}.BOX_IMAGE_HEX_FILE="{mem.as_posix()}"', + "-P", f"{top}.BOX_IMG_X_STEP={BIMG_X_STEP}", + "-P", f"{top}.BOX_IMG_Y_STEP={BIMG_Y_STEP}", + str(RTL / "vtpgz_core.v"), str(HERE / "tb_ppc_capture.v")] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + fails.append(f"box-image ppc={ppc} build: {r.stdout}{r.stderr}") + continue + for pname, pat in (("solid", PAT_SOLID), ("checker", PAT_CHECKER)): + hexf = tmp / f"boximg_{ppc}_{pname}.hex" + run_capture(vvp_bin, pat, 32, 12, hexf) + sim = load_beats(hexf) + cfg = VtpgzConfig(width=32, height=12, pattern=pat, + output_mode=MODE_RGB, bpc=8, pixels_per_clock=ppc, + box_image_w=BIMG_W_T, box_image_h=BIMG_H_T, + box_img_x_step=BIMG_X_STEP, box_img_y_step=BIMG_Y_STEP, + box_image_rgb888=BIMG_DATA, **HARNESS_CFG) + mod = render_frame_beats(cfg) + if sim != mod: + first = next((i for i, (a, b) in enumerate(zip(sim, mod)) if a != b), + min(len(sim), len(mod))) + fails.append(f"box-image ppc={ppc} pat={pname}: first_diff@{first} " + f"sim=0x{sim[first]:X} mod=0x{mod[first]:X}") + else: + print(f" OK box-image ppc={ppc} pat={pname} ({len(sim)} beats)") + return fails + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--ppc", type=int, default=None, choices=[1, 2, 4, 8]) @@ -162,8 +261,13 @@ def main() -> int: all_fails: list[str] = [] n = 0 + global IMAGE_HEX_PATH with tempfile.TemporaryDirectory() as td: tmp = Path(td) + # Write the test image .mem for $readmemh (forward slashes for iverilog). + mem = tmp / "ppc_test_img.mem" + mem.write_text("\n".join(f"{w:06x}" for w in IMAGE_DATA) + "\n") + IMAGE_HEX_PATH = mem.as_posix() for ppc in ppcs: # width must be a multiple of ppc; pick one that is for the sweep. width = args.width @@ -179,6 +283,13 @@ def main() -> int: except RuntimeError as e: all_fails.append(f"ppc={ppc} mode={mode} bpc={bpc}: {e}") + # Dedicated box-image overlay check (only when sweeping all ppc). + if not args.ppc: + try: + all_fails += check_box_image(tmp) + except RuntimeError as e: + all_fails.append(f"box-image: {e}") + print() if all_fails: print(f"FAIL: {len(all_fails)} mismatch(es) across {n} configs") diff --git a/sim/tb_ppc_capture.v b/sim/tb_ppc_capture.v index 7651536..8ff924d 100644 --- a/sim/tb_ppc_capture.v +++ b/sim/tb_ppc_capture.v @@ -36,6 +36,20 @@ module tb_ppc_capture; parameter integer EN_VGRAD = 0; parameter integer EN_RAMP = 0; parameter integer EN_NOISE = 0; + parameter integer EN_IMAGE = 0; + parameter integer IMAGE_W = 16; + parameter integer IMAGE_H = 16; + parameter integer IMAGE_OUT_W = 16; + parameter integer IMAGE_OUT_H = 16; + parameter IMAGE_HEX_FILE = "tests/images/mandrill_128x128.mem"; + parameter integer EN_BOX_IMAGE = 0; + parameter integer BOX_IMAGE_W = 8; + parameter integer BOX_IMAGE_H = 8; + parameter BOX_IMAGE_HEX_FILE = "tests/images/mandrill_32x32.mem"; + // Runtime Q16 box-image scaler steps (host-computed). 0 = box-image + // inactive (solid box). Set non-zero to exercise the overlay. + parameter [31:0] BOX_IMG_X_STEP = 32'h0; + parameter [31:0] BOX_IMG_Y_STEP = 32'h0; localparam integer PIX_TDATA_WIDTH = (OUTPUT_MODE == `VTPGZ_MODE_RGB) ? (((3*BPC + 7) / 8) * 8) : @@ -70,8 +84,16 @@ module tb_ppc_capture; .EN_GRID (EN_GRID), .EN_RAMP (EN_RAMP), .EN_NOISE (EN_NOISE), - .EN_IMAGE (0), - .EN_BOX_IMAGE (0), + .EN_IMAGE (EN_IMAGE), + .IMAGE_W (IMAGE_W), + .IMAGE_H (IMAGE_H), + .IMAGE_OUT_W (IMAGE_OUT_W), + .IMAGE_OUT_H (IMAGE_OUT_H), + .IMAGE_HEX_FILE(IMAGE_HEX_FILE), + .EN_BOX_IMAGE (EN_BOX_IMAGE), + .BOX_IMAGE_W (BOX_IMAGE_W), + .BOX_IMAGE_H (BOX_IMAGE_H), + .BOX_IMAGE_HEX_FILE(BOX_IMAGE_HEX_FILE), .OUTPUT_MODE (OUTPUT_MODE), .YUV_SUBSAMPLE(YUV_SUBSAMPLE), .RAW_BAYER (RAW_BAYER), @@ -102,8 +124,8 @@ module tb_ppc_capture; .cfg_vg_step(16'd128), .cfg_box_border_color(24'hFF_00_00), .cfg_box_border_width(8'd2), - .cfg_box_img_x_step(32'h0), - .cfg_box_img_y_step(32'h0), + .cfg_box_img_x_step(BOX_IMG_X_STEP), + .cfg_box_img_y_step(BOX_IMG_Y_STEP), .sts_busy(), .sts_frame_count(), .m_axis_tdata(m_tdata), From d20e27d511adf12f2db0a8443f2ac5c285ea2ed5 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Wed, 29 Jul 2026 21:31:08 +0800 Subject: [PATCH 06/13] @ Add cocotb-driven PPC data-path verification Icarus-backed cocotb suite (run_ppc.py, test_ppc.py) drives the core over AXI-Lite and checks each AXIS beat against the reference model for PIXELS_PER_CLOCK 1/2/4/8 across RGB/RAW/YUV, all 8 patterns. Icarus is used since Verilator returns a sampled-once packed tdata. 12 suites pass. @ --- .gitignore | 1 + sim/cocotb/run_cocotb.py | 13 ++- sim/cocotb/run_ppc.py | 162 ++++++++++++++++++++++++++++++ sim/cocotb/test_ppc.py | 210 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 sim/cocotb/run_ppc.py create mode 100644 sim/cocotb/test_ppc.py diff --git a/.gitignore b/.gitignore index 184099b..7dce850 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ sim/obj_capture_*/ sim/obj_seq/ sim/logs/ sim/cocotb/build/ +sim/cocotb/build_ppc/ *.vcd *.vvp synth/results/ diff --git a/sim/cocotb/run_cocotb.py b/sim/cocotb/run_cocotb.py index c6f470d..d7749b9 100644 --- a/sim/cocotb/run_cocotb.py +++ b/sim/cocotb/run_cocotb.py @@ -81,10 +81,15 @@ # Python-smoke CI job. A cocotb-driven YUV spec test was attempted but # the cocotb 2.0.1 + Verilator 5.048 combination returns a sampled-once # value for the packed m_axis_tdata output even though the C++ harness - # reads it correctly (see test_tready_probe.py for the diagnosis). The - # byte-exact RTL<->model gate in sim/run_sim.py covers the data path - # across all 20 mode/bpc configs, so cocotb is currently scoped to the - # AXI-Lite control plane and AXIS handshake protocol. + # reads it correctly (see test_tready_probe.py for the diagnosis). So the + # Verilator-backed cocotb suites here are scoped to the AXI-Lite control + # plane and AXIS handshake protocol. + # + # The cocotb *data-path* verification (every AXIS beat compared against + # the reference model, swept over PIXELS_PER_CLOCK 1/2/4/8 x RGB/RAW/YUV) + # lives in run_ppc.py and runs under the Icarus runner, which does read + # the packed m_axis_tdata correctly. The byte-exact C++ gate in + # sim/run_sim.py remains the Verilator-backed data-path regression. ] diff --git a/sim/cocotb/run_ppc.py b/sim/cocotb/run_ppc.py new file mode 100644 index 0000000..d9bf873 --- /dev/null +++ b/sim/cocotb/run_ppc.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Drive the cocotb pixels-per-clock data-path suite with Icarus Verilog. + +The Verilator+cocotb path (run_cocotb.py) can't read the packed +m_axis_tdata output -- cocotb 2.0.1 + Verilator 5.048 returns a +sampled-once value (see test_tready_probe.py). Icarus reads the packed +output correctly, so the PPC data-path verification -- which must compare +every AXIS beat against the reference model -- runs here under Icarus. + +Each suite is one build (OUTPUT_MODE / BPC / PIXELS_PER_CLOCK differ) and +the test module (test_ppc) sweeps every runtime pattern within it. The +config is passed to the test via VTPGZ_* env vars so it can construct the +matching model config. + +Usage: + python3 sim/cocotb/run_ppc.py [suite1 [suite2 ...]] + +With no arguments every suite in SUITES runs. +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +try: + from cocotb_tools.runner import get_runner +except ImportError: + from cocotb.runner import get_runner # type: ignore + +HERE = Path(__file__).resolve().parent +SIM_DIR = HERE.parent +RTL_DIR = (SIM_DIR / ".." / "rtl").resolve() +BUILD = HERE / "build_ppc" + +TOP = "vtpgz_axilite_top" +RTL_SRCS = [ + RTL_DIR / "vtpgz_axil_regs.v", + RTL_DIR / "vtpgz_core.v", + RTL_DIR / "vtpgz_axilite_top.v", +] + +WIDTH = 32 # multiple of 8 so every PPC divides it evenly +HEIGHT = 12 + +# (mode, bpc, yuv_sub, raw_bayer) triples to cross with each PPC. +MODE_RGB, MODE_RAW, MODE_YUV = 0, 1, 2 +YUV_422 = 1 +RAW_RGGB = 1 +_MODES = [ + ("rgb8", dict(mode=MODE_RGB, bpc=8, sub=0, bayer=0)), + ("raw12", dict(mode=MODE_RAW, bpc=12, sub=0, bayer=RAW_RGGB)), + ("yuv422", dict(mode=MODE_YUV, bpc=8, sub=YUV_422, bayer=0)), +] +_PPCS = [1, 2, 4, 8] + + +def _build_suites() -> list[dict]: + suites = [] + for mname, m in _MODES: + for ppc in _PPCS: + suites.append({ + "name": f"ppc{ppc}_{mname}", + "ppc": ppc, + "mode": m, + }) + return suites + + +SUITES = _build_suites() + + +def _xml_has_failures(xml_text: str) -> bool: + return " int: + if not shutil.which("iverilog"): + print("ERROR: 'iverilog' not found in PATH.", file=sys.stderr) + return 2 + + env_path = os.pathsep.join( + filter(None, [str(HERE), os.environ.get("PYTHONPATH", "")])) + os.environ["PYTHONPATH"] = env_path + + requested = set(argv) if argv else None + if requested: + unknown = requested - {s["name"] for s in SUITES} + if unknown: + print(f"ERROR: unknown suite(s): {sorted(unknown)}", file=sys.stderr) + return 2 + + fails: list[tuple[str, str]] = [] + for suite in SUITES: + if requested is not None and suite["name"] not in requested: + continue + name = suite["name"] + ppc = suite["ppc"] + m = suite["mode"] + params = { + "OUTPUT_MODE": m["mode"], + "BPC": m["bpc"], + "YUV_SUBSAMPLE": m["sub"], + "RAW_BAYER": m["bayer"], + "RGB_ORDER": 0, + "PIXELS_PER_CLOCK": ppc, + "EN_IMAGE": 0, + "EN_BOX_IMAGE": 0, + "LINE_GAP_CYCLES": 1, + } + build_dir = BUILD / name + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.mkdir(parents=True, exist_ok=True) + print(f"=== cocotb PPC suite: {name} params={params} ===", flush=True) + + # Config the test needs to build the matching model cfg. + os.environ["VTPGZ_PPC"] = str(ppc) + os.environ["VTPGZ_MODE"] = str(m["mode"]) + os.environ["VTPGZ_BPC"] = str(m["bpc"]) + os.environ["VTPGZ_YUV_SUB"] = str(m["sub"]) + os.environ["VTPGZ_RAW_BAYER"] = str(m["bayer"]) + os.environ["VTPGZ_RGB_ORDER"] = "0" + os.environ["VTPGZ_WIDTH"] = str(WIDTH) + os.environ["VTPGZ_HEIGHT"] = str(HEIGHT) + + runner = get_runner("icarus") + runner.build( + sources=[str(s) for s in RTL_SRCS], + hdl_toplevel=TOP, + includes=[str(RTL_DIR)], + parameters=params, + build_dir=str(build_dir), + timescale=("1ns", "1ps"), + always=True, + ) + runner.test( + hdl_toplevel=TOP, + test_module="test_ppc", + build_dir=str(build_dir), + ) + results = build_dir / "results.xml" + if not results.exists(): + fails.append((name, "no results.xml emitted")) + continue + text = results.read_text(encoding="utf-8", errors="replace") + if _xml_has_failures(text): + fails.append((name, "see results.xml")) + print(text[-3000:]) + + if fails: + print("\nFAILED PPC SUITES:") + for name, why in fails: + print(f" - {name}: {why}") + return 1 + print(f"\nALL {len([s for s in SUITES if requested is None or s['name'] in requested])} COCOTB PPC SUITES PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/sim/cocotb/test_ppc.py b/sim/cocotb/test_ppc.py new file mode 100644 index 0000000..64c6af2 --- /dev/null +++ b/sim/cocotb/test_ppc.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: 2026 Leonardo Capossio - bard0 design - hello@bard0.com +# SPDX-License-Identifier: Apache-2.0 +"""cocotb data-path verification for the pixels-per-clock feature. + +Runs under the Icarus runner (cocotb 2.x + Verilator returns a stale packed +m_axis_tdata read -- see test_tready_probe.py -- so the AXIS *data* path is +driven from cocotb via Icarus here). Programs vtpgz_axilite_top over AXI-Lite, +captures one frame of AXI-Stream beats per pattern, and compares each beat +against the Python reference model (render_frame_beats). + +Build parameters (PIXELS_PER_CLOCK / OUTPUT_MODE / BPC / ...) are fixed per +build; the test sweeps the runtime PATTERN_SEL. Config is passed in via env +vars set by run_ppc.py so the test can build the matching model config. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import cocotb +from cocotb.clock import Clock +from cocotb.triggers import RisingEdge + +# Make the reference model importable. +import sys +HW_PY = (Path(__file__).resolve().parent / ".." / ".." / + "hw" / "arty_a7_100t" / "python").resolve() +sys.path.insert(0, str(HW_PY)) +from vtpgz_model import ( # noqa: E402 + VtpgzConfig, render_frame_beats, + PAT_SOLID, PAT_GRID, PAT_CHECKER, PAT_COLORBAR, + PAT_HGRAD, PAT_VGRAD, PAT_RAMP, PAT_NOISE, +) + +# ---- register offsets (mirror vtpgz_defs.vh) ---- +REG_CONTROL = 0x08 +REG_IMG_WIDTH = 0x10 +REG_IMG_HEIGHT = 0x14 +REG_PATTERN_SEL = 0x18 +REG_PPC = 0x30 +REG_SOLID_COLOR = 0x20 +REG_BOX_COLOR = 0x24 +REG_BOX_SIZE = 0x28 +REG_BOX_SPEED = 0x2C +REG_GRID_SPACING = 0x34 +REG_GRID_COLOR = 0x38 +REG_CHECKER_SIZE = 0x3C +REG_FRAME_RATE = 0x40 +REG_BAR_WIDTH = 0x44 +REG_HG_STEP = 0x48 +REG_VG_STEP = 0x4C +REG_BOX_BORDER = 0x50 + +# ---- config (kept identical to the model cfg built below) ---- +CFG = dict( + solid_color=0x3CA510, box_color=0x00FF00, + box_width=12, box_height=8, box_dx=1, box_dy=1, + box_border_color=0xFF0000, box_border_width=2, + grid_spacing=7, grid_color=0xFFFFFF, checker_size=5, + hg_step=64, vg_step=128, bar_width=8, +) + +PATTERNS = [ + ("solid", PAT_SOLID), ("grid", PAT_GRID), ("checker", PAT_CHECKER), + ("colorbar", PAT_COLORBAR), ("hgrad", PAT_HGRAD), ("vgrad", PAT_VGRAD), + ("ramp", PAT_RAMP), ("noise", PAT_NOISE), +] + +CLK_NS = 10 + + +def _env_int(name, default): + return int(os.environ.get(name, str(default))) + + +async def _axi_write(dut, addr, data, timeout=200): + dut.s_axi_awaddr.value = addr + dut.s_axi_awvalid.value = 1 + dut.s_axi_wdata.value = data + dut.s_axi_wstrb.value = 0xF + dut.s_axi_wvalid.value = 1 + dut.s_axi_bready.value = 1 + aw = w = b = False + for _ in range(timeout): + await RisingEdge(dut.aclk) + if not aw and int(dut.s_axi_awready.value): + dut.s_axi_awvalid.value = 0; aw = True + if not w and int(dut.s_axi_wready.value): + dut.s_axi_wvalid.value = 0; w = True + if not b and int(dut.s_axi_bvalid.value): + dut.s_axi_bready.value = 0; b = True + if aw and w and b: + return + raise AssertionError(f"AXI write to 0x{addr:02X} timed out") + + +async def _axi_read(dut, addr, timeout=200): + dut.s_axi_araddr.value = addr + dut.s_axi_arvalid.value = 1 + dut.s_axi_rready.value = 1 + val = None + for _ in range(timeout): + await RisingEdge(dut.aclk) + if int(dut.s_axi_arready.value): + dut.s_axi_arvalid.value = 0 + if int(dut.s_axi_rvalid.value): + val = int(dut.s_axi_rdata.value) + dut.s_axi_rready.value = 0 + return val + raise AssertionError(f"AXI read from 0x{addr:02X} timed out") + + +async def _reset(dut): + dut.aresetn.value = 0 + dut.s_axi_awvalid.value = 0 + dut.s_axi_wvalid.value = 0 + dut.s_axi_bready.value = 0 + dut.s_axi_arvalid.value = 0 + dut.s_axi_rready.value = 0 + dut.m_axis_tready.value = 1 + dut.frame_sync_in.value = 0 + for _ in range(10): + await RisingEdge(dut.aclk) + dut.aresetn.value = 1 + for _ in range(5): + await RisingEdge(dut.aclk) + + +async def _program(dut, width, height, pat): + await _axi_write(dut, REG_CONTROL, 0) # disable while programming + await _axi_write(dut, REG_IMG_WIDTH, width) + await _axi_write(dut, REG_IMG_HEIGHT, height) + await _axi_write(dut, REG_BAR_WIDTH, CFG["bar_width"]) + await _axi_write(dut, REG_HG_STEP, CFG["hg_step"]) + await _axi_write(dut, REG_VG_STEP, CFG["vg_step"]) + await _axi_write(dut, REG_CHECKER_SIZE, CFG["checker_size"]) + await _axi_write(dut, REG_GRID_SPACING, CFG["grid_spacing"]) + await _axi_write(dut, REG_GRID_COLOR, CFG["grid_color"]) + await _axi_write(dut, REG_SOLID_COLOR, CFG["solid_color"]) + await _axi_write(dut, REG_BOX_COLOR, CFG["box_color"]) + await _axi_write(dut, REG_BOX_SIZE, (CFG["box_width"] << 16) | CFG["box_height"]) + await _axi_write(dut, REG_BOX_SPEED, (CFG["box_dx"] << 16) | CFG["box_dy"]) + await _axi_write(dut, REG_BOX_BORDER, + (CFG["box_border_width"] << 24) | CFG["box_border_color"]) + await _axi_write(dut, REG_FRAME_RATE, 50) + await _axi_write(dut, REG_PATTERN_SEL, pat) + await _axi_write(dut, REG_CONTROL, 1) # enable, internal sync + + +async def _capture_frame(dut, n_beats, max_cycles): + beats = [] + started = False + cycles = 0 + while len(beats) < n_beats and cycles < max_cycles: + await RisingEdge(dut.aclk) + cycles += 1 + if int(dut.m_axis_tvalid.value) and int(dut.m_axis_tready.value): + if not started: + if int(dut.m_axis_tuser.value): + started = True + else: + continue + beats.append(int(dut.m_axis_tdata.value)) + return beats + + +@cocotb.test() +async def ppc_datapath_vs_model(dut): + ppc = _env_int("VTPGZ_PPC", 1) + mode = _env_int("VTPGZ_MODE", 0) + bpc = _env_int("VTPGZ_BPC", 8) + sub = _env_int("VTPGZ_YUV_SUB", 0) + bayer = _env_int("VTPGZ_RAW_BAYER", 1) + order = _env_int("VTPGZ_RGB_ORDER", 0) + width = _env_int("VTPGZ_WIDTH", 32) + height = _env_int("VTPGZ_HEIGHT", 12) + assert width % ppc == 0, "width must be a multiple of PIXELS_PER_CLOCK" + + cocotb.start_soon(Clock(dut.aclk, CLK_NS, unit="ns").start()) + await _reset(dut) + + # Confirm the build's PPC matches what the runner intended. + ppc_rb = await _axi_read(dut, REG_PPC) + assert ppc_rb == ppc, f"PPC readback {ppc_rb} != expected {ppc}" + + beats_per_frame = (width // ppc) * height + max_cycles = beats_per_frame * 40 + 20000 + + for pname, pat in PATTERNS: + await _program(dut, width, height, pat) + sim = await _capture_frame(dut, beats_per_frame, max_cycles) + assert len(sim) == beats_per_frame, ( + f"{pname}: captured {len(sim)}/{beats_per_frame} beats") + + cfg = VtpgzConfig(width=width, height=height, pattern=pat, + output_mode=mode, yuv_subsample=sub, raw_bayer=bayer, + rgb_order=order, bpc=bpc, pixels_per_clock=ppc, **CFG) + mod = render_frame_beats(cfg) + assert len(mod) == len(sim) + first = next((i for i, (a, b) in enumerate(zip(sim, mod)) if a != b), -1) + assert first == -1, ( + f"{pname} ppc={ppc} mode={mode} bpc={bpc}: beat {first} " + f"sim=0x{sim[first]:X} mod=0x{mod[first]:X}") + dut._log.info(f"OK {pname} ppc={ppc} mode={mode} bpc={bpc} " + f"({len(sim)} beats)") + + # Toggle enable off so the next pattern starts a fresh frame. + await _axi_write(dut, REG_CONTROL, 0) + for _ in range(5): + await RisingEdge(dut.aclk) From 6c6af951c41bb4c87afe5c182f2ff27109f638ac Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Thu, 30 Jul 2026 14:12:58 +0800 Subject: [PATCH 07/13] @ Document cocotb test runners in README Add a cocotb subsection under "How to test it" covering run_cocotb.py (control plane) and run_ppc.py (PPC data path vs model), and list the cocotb/ dir plus the iverilog PPC gate in the file-layout section. @ --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 4019bea..2e61cc5 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,28 @@ Outputs land in `sim/logs/`: - `coverage_summary.txt` — overall coverage summary - `annotated/` — line-annotated source (uncovered lines marked `%00`) +### cocotb + +Python-authored spec/property tests live under `sim/cocotb/`. There are two +runners, split by simulator because of a tool quirk: + +```sh +# Control plane + AXIS handshake (Verilator runner). +python sim/cocotb/run_cocotb.py + +# Pixels-per-clock data path (Icarus runner): programs the core over +# AXI-Lite, captures every AXIS beat, and checks it beat-exact against the +# Python reference model across PIXELS_PER_CLOCK 1/2/4/8 × RGB/RAW/YUV, +# sweeping all 8 synthetic patterns per build (12 suites). +python sim/cocotb/run_ppc.py +``` + +The PPC data-path suite uses Icarus because cocotb 2.0.1 + Verilator returns +a sampled-once value for the packed `m_axis_tdata`; Icarus reads it +correctly. The Verilator-backed cocotb suites are therefore scoped to the +control plane, and the byte-exact C++ gate in `sim/run_sim.py` remains the +Verilator-backed data-path regression. + ### Hardware test on Arty A7-100T A complete reference design under `hw/arty_a7_100t/` instantiates the VTPGZ @@ -812,6 +834,10 @@ sim/ sim_capture_seq.cpp multi-capture (sequential) sim ↔ model gate sim_top.v tpg + frame_capture wrapper for the seq harness run_sim.py Verilator orchestration (lint/build/run/cov/all_modes) + check_ppc_vs_model.py iverilog ↔ model beat-exact PPC gate (all patterns) + tb_ppc_capture.v iverilog PPC capture harness (port-driven core) + cocotb/ cocotb suites (run_cocotb.py control plane; + run_ppc.py PPC data path vs model) synth/ synth_matrix.tcl Vivado synth-only TCL for one config run_matrix.py driver: synth N parameter configs, build matrix CSV From a9c4ec025e4a48603b80aa3109955ff53fea954c Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Fri, 31 Jul 2026 11:59:17 +0800 Subject: [PATCH 08/13] @ Add PPC resource sweep and refresh resource matrix run_matrix.py gains a `ppc` mode that synthesizes the all-patterns RGB-8b build over PIXELS_PER_CLOCK 1/2/4/8. README resource tables are regenerated against current RTL (they predated the line-gap feature): mode/BPC sweep, per-pattern deltas, and tiniest build all refreshed, and a new pixels-per-clock table added. Scaling is sub-linear: 8x throughput for +56% LUT / +36% FF. PPC=1 is unchanged from pre-feature RTL. @ --- README.md | 103 +++++++++++++++++++++++++++----------------- synth/run_matrix.py | 29 +++++++++++++ 2 files changed, 93 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2e61cc5..6dc5cda 100644 --- a/README.md +++ b/README.md @@ -724,8 +724,10 @@ your own pending-bit FF before `frame_sync_in`. The numbers above include the full demo wrapper: `vtpgz_axilite_top` + `frame_capture` + the fpgacapZero `fcapz_ejtagaxi_xilinx7` JTAG-to-AXI bridge + a 32 KB on-chip BRAM frame buffer + clk_gen MMCM. The vtpgZero -core itself (`rtl/vtpgz_core.v` + AXI-Lite wrapper) is roughly half of -the total (see the resource matrix below for the standalone numbers). +core itself (`rtl/vtpgz_core.v` + AXI-Lite wrapper) is the largest single +block; see the standalone out-of-context synth numbers in the resource +matrix below (note those are OOC synth, whereas this row is full-design +post-implementation, so the two aren't directly comparable). ### Resource matrix per build-time configuration @@ -738,22 +740,22 @@ with Vivado 2025.2's default `synth_design` flow. Reproducible with | Config | LUT | FF | BRAM36 | |---|---:|---:|---:| -| `full_rgb_8b` | 968 | 908 | 0 | -| `full_rgb_10b` | 968 | 914 | 0 | -| `full_rgb_12b` | 968 | 920 | 0 | -| `full_rgb_14b` | 967 | 920 | 0 | -| `full_rgb_16b` | 967 | 920 | 0 | -| `full_raw_8b` | 976 | 894 | 0 | -| `full_raw_10b` | 978 | 896 | 0 | -| `full_raw_12b` | 980 | 898 | 0 | -| `full_raw_14b` | 980 | 898 | 0 | -| `full_raw_16b` | 980 | 898 | 0 | -| `full_yuv_8b` | 962 | 908 | 0 | -| `full_yuv_10b` | 954 | 914 | 0 | -| `full_yuv_12b` | 961 | 920 | 0 | -| `full_yuv_14b` | 962 | 920 | 0 | -| `full_yuv_16b` | 962 | 920 | 0 | -| `full_yuv422_16b` | 972 | 909 | 0 | +| `full_rgb_8b` | 1270 | 1212 | 0 | +| `full_rgb_10b` | 1270 | 1218 | 0 | +| `full_rgb_12b` | 1270 | 1224 | 0 | +| `full_rgb_14b` | 1270 | 1224 | 0 | +| `full_rgb_16b` | 1270 | 1224 | 0 | +| `full_raw_8b` | 1278 | 1200 | 0 | +| `full_raw_10b` | 1280 | 1202 | 0 | +| `full_raw_12b` | 1282 | 1204 | 0 | +| `full_raw_14b` | 1282 | 1204 | 0 | +| `full_raw_16b` | 1282 | 1204 | 0 | +| `full_yuv_8b` | 1232 | 1210 | 0 | +| `full_yuv_10b` | 1236 | 1216 | 0 | +| `full_yuv_12b` | 1236 | 1222 | 0 | +| `full_yuv_14b` | 1236 | 1222 | 0 | +| `full_yuv_16b` | 1232 | 1222 | 0 | +| `full_yuv422_16b` | 1245 | 1212 | 0 | The YUV path produces `{Y,Cb,Cr}` directly from the pattern generators (precomputed BT.601 palette for the colorbar, neutral chroma for @@ -766,28 +768,28 @@ YUV logic added in recent revisions. | Config | LUT | FF | |---|---:|---:| -| `baseline_solid_yuv` | 402 | 732 | -| `only_colorbar_yuv` | 462 | 752 | -| `only_hgrad_yuv` | 431 | 753 | -| `only_vgrad_yuv` | 433 | 753 | -| `only_checker_yuv` | 431 | 767 | -| `only_moving_box_yuv` | 695 | 767 | -| `only_grid_yuv` | 520 | 765 | -| `only_ramp_yuv` | 431 | 753 | -| `only_noise_yuv` | 409 | 749 | - -Per-feature deltas relative to `baseline_solid_yuv` (402 LUT / 732 FF): +| `baseline_solid_yuv` | 526 | 926 | +| `only_colorbar_yuv` | 549 | 955 | +| `only_hgrad_yuv` | 548 | 951 | +| `only_vgrad_yuv` | 558 | 951 | +| `only_checker_yuv` | 564 | 962 | +| `only_moving_box_yuv` | 1054 | 1059 | +| `only_grid_yuv` | 569 | 959 | +| `only_ramp_yuv` | 548 | 951 | +| `only_noise_yuv` | 531 | 947 | + +Per-feature deltas relative to `baseline_solid_yuv` (526 LUT / 926 FF): | Feature | ΔLUT | ΔFF | |---|---:|---:| -| `EN_COLORBAR` | +60 | +20 | -| `EN_HGRAD` | +29 | +21 | -| `EN_VGRAD` | +31 | +21 | -| `EN_CHECKER` | +29 | +35 | -| `EN_MOVING_BOX` | **+293** | +35 | -| `EN_GRID` | +118 | +33 | -| `EN_RAMP` | +29 | +21 | -| `EN_NOISE` | +7 | +17 | +| `EN_COLORBAR` | +23 | +29 | +| `EN_HGRAD` | +22 | +25 | +| `EN_VGRAD` | +32 | +25 | +| `EN_CHECKER` | +38 | +36 | +| `EN_MOVING_BOX` | **+528** | +133 | +| `EN_GRID` | +43 | +33 | +| `EN_RAMP` | +22 | +25 | +| `EN_NOISE` | +5 | +21 | `EN_MOVING_BOX` is by far the most expensive feature (the bouncing position arithmetic and per-pixel range comparators for the overlay). `EN_NOISE` is @@ -797,12 +799,35 @@ the cheapest. There are no multiplies anywhere in the design. | Config | LUT | FF | |---|---:|---:| -| `tiny_raw_8b` (only EN_SOLID, OUTPUT_MODE=RAW, BPC=8) | **410** | 718 | +| `tiny_raw_8b` (only EN_SOLID, OUTPUT_MODE=RAW, BPC=8) | **534** | 914 | This is the absolute minimum: 1 pattern, RAW Bayer 8 bpc. -~410 LUTs total. Useful as an image-sensor-emulator for camera/ISP +~534 LUTs total. Useful as an image-sensor-emulator for camera/ISP bring-up where you only need a controllable raw stream. +#### Pixels-per-clock sweep + +All-patterns RGB-8b build swept over `PIXELS_PER_CLOCK` (the `ppc1` row is +the same build as `full_rgb_8b` above). Mode/BPC/patterns are held fixed so +the numbers isolate the cost of widening the per-lane datapath from 1 to N +pixels per beat. Reproducible with `python synth/run_matrix.py ppc`. + +| Config | LUT | FF | beat width | vs `ppc1` | +|---|---:|---:|---:|---| +| `ppc1_full_rgb_8b` | 1270 | 1212 | 24b | — | +| `ppc2_full_rgb_8b` | 1338 | 1288 | 48b | +5% LUT / +6% FF | +| `ppc4_full_rgb_8b` | 1554 | 1406 | 96b | +22% LUT / +16% FF | +| `ppc8_full_rgb_8b` | 1980 | 1644 | 192b | +56% LUT / +36% FF | + +Scaling is strongly sub-linear: 8× the per-clock pixel throughput costs only +**+56% LUT / +36% FF**. The per-pixel packers and the single-step +counter/accumulator chains replicate per lane, but the shared timing FSM, +moving-box position arithmetic, and config registers do not. `PIXELS_PER_CLOCK=1` +is the default and its netlist is unchanged from releases before the feature +existed (verified: the pre-feature commit synthesizes to the identical +1270 LUT / 1212 FF). There is no BRAM or DSP cost at any PPC in this build; +the `EN_IMAGE` patterns would add BRAM that scales with PPC via replication. + **Test conditions for the matrix above**: Vivado 2025.2, target `xc7a100tcsg324-1` -1 speed grade, `synth_design` default strategy, out-of-context mode. No timing constraints applied (so the synth tool diff --git a/synth/run_matrix.py b/synth/run_matrix.py index aea93a7..2470c9d 100644 --- a/synth/run_matrix.py +++ b/synth/run_matrix.py @@ -45,6 +45,7 @@ "RAW_BAYER": 1, # 0=plain 1=RGGB "RGB_ORDER": 0, # 0=Xilinx 1=legacy "BPC": 8, + "PIXELS_PER_CLOCK": 1, # 1/2/4/8 pixels packed per AXI-Stream beat } @@ -95,10 +96,38 @@ def run_one(tag: str, pat_over: dict[str, int], return util +def ppc_configs() -> list[tuple[str, dict[str, int], dict[str, int]]]: + """All-patterns RGB-8b build swept over PIXELS_PER_CLOCK 1/2/4/8. + + RGB-8b is the reference point (matches full_rgb_8b in the mode sweep, + i.e. the PPC=1 row). Holding mode/BPC/patterns fixed isolates the cost + of widening the per-lane datapath from 1 to N pixels per beat. + """ + full_pats = {f: 1 for f in PATTERN_FEATURES} + cfgs = [] + for ppc in (1, 2, 4, 8): + cfgs.append((f"ppc{ppc}_full_rgb_8b", dict(full_pats), + {"OUTPUT_MODE": 0, "BPC": 8, "PIXELS_PER_CLOCK": ppc})) + return cfgs + + def main() -> int: RESULTS.mkdir(parents=True, exist_ok=True) configs: list[tuple[str, dict[str, int], dict[str, int]]] = [] + # `run_matrix.py ppc` synthesizes only the PIXELS_PER_CLOCK sweep. + if len(sys.argv) > 1 and sys.argv[1] == "ppc": + configs = ppc_configs() + results = {tag: run_one(tag, p, m) for tag, p, m in configs} + print("\n\n## PPC resource sweep (synth-only, xc7a100tcsg324-1)\n") + print("| Config | LUT | FF | BRAM36 | DSP |") + print("|---|---:|---:|---:|---:|") + for tag, _, _ in configs: + u = results.get(tag, {}) + print(f"| `{tag}` | {u.get('LUT','?')} | {u.get('FF','?')} " + f"| {u.get('BRAM36','?')} | {u.get('DSP','?')} |") + return 0 + # ----- Per-pattern sweeps (full YUV build, isolating each pattern) ----- base_off = {f: 0 for f in PATTERN_FEATURES} base_off["EN_SOLID"] = 1 From 0070ce68e8812319c1ac909aee626b4a8ac6ccef Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Sat, 1 Aug 2026 14:48:18 +0800 Subject: [PATCH 09/13] @ Fix Verilator 5.020 CI lint/build failures CI uses apt Verilator 5.020, which the PPC RTL tripped three ways: - UNOPTFLAT on the per-lane chain arrays (bpc_l/cxc/hga_l/lf_l/etc.): add /* verilator split_var */ so each element is an independent net. - V3Number internal crash + out-of-range part selects on pack_pixel: rewrite packing as width-safe assigns + clamped left shifts (no zero-width replications, no dead-branch range errors). Byte-exact. - coverage_off on the mode/PPC dead-branch helpers (pack_pixel, bar_palette, pat_c*_bus, PPC readback) to restore the 100% gate. Verified on 5.020: lint, regression (100%), all_modes, check_seq. @ --- .gitignore | 1 + rtl/vtpgz_axil_regs.v | 5 +++ rtl/vtpgz_core.v | 78 ++++++++++++++++++++++++++++++++----------- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 7dce850..1b292b6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ synth/results/ synth/run_matrix.log hw/arty_a7_100t/build/ hw/arty_a7_100t/build.log +hw/arty_a7_100t/build_ppc1.log hw/kv260/build/ .Xil/ vivado*.jou diff --git a/rtl/vtpgz_axil_regs.v b/rtl/vtpgz_axil_regs.v index a848e05..b16b3e7 100644 --- a/rtl/vtpgz_axil_regs.v +++ b/rtl/vtpgz_axil_regs.v @@ -228,7 +228,12 @@ module vtpgz_axil_regs #( `VTPGZ_REG_IMG_WIDTH : s_axi_rdata <= reg_img_width; `VTPGZ_REG_IMG_HEIGHT : s_axi_rdata <= reg_img_height; `VTPGZ_REG_PATTERN_SEL : s_axi_rdata <= reg_pattern_sel; + // verilator coverage_off + // RO build-time param readback; exercised by the cocotb + // PPC suite and the Arty HW test (both read 0x30), not the + // coverage register sweep in sim_main.cpp. `VTPGZ_REG_PIXELS_PER_CLOCK : s_axi_rdata <= PIXELS_PER_CLOCK; + // verilator coverage_on `VTPGZ_REG_COLOR_FORMAT : s_axi_rdata <= { TDATA_WIDTH[15:0], BPC[7:0], diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index 98117b4..9dfb187 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -174,6 +174,13 @@ module vtpgz_core #( // Bit-shrink/grow shift amounts (constant): mirror the BPC pack stage. localparam integer SHIFT_DN = (BPC <= 12) ? (12 - BPC) : 0; // truncate LSBs localparam integer SHIFT_UP = (BPC > 12) ? (BPC - 12) : 0; // zero-extend LSBs + // Legacy-order (components in MSBs) left-shift amounts, clamped to be + // non-negative for every build. Only the build's native mode is reachable; + // in a non-native mode's dead branch these could otherwise go negative + // (3*BPC/2*BPC > PIX_TDATA_WIDTH), so clamp to 0 to keep the dead code + // width-legal on Verilator 5.020. + localparam integer PAD3 = (PIX_TDATA_WIDTH >= 3*BPC) ? (PIX_TDATA_WIDTH - 3*BPC) : 0; + localparam integer PAD2 = (PIX_TDATA_WIDTH >= 2*BPC) ? (PIX_TDATA_WIDTH - 2*BPC) : 0; // ---------------- effective configuration ---------------- // Clamp unsafe zero / over-large geometry values so malformed software @@ -402,6 +409,11 @@ module vtpgz_core #( // Mode-aware palette as a function so each lane can look up its own bar // index. In RGB/RAW the triple is {R,G,B}; in YUV it is {Y,Cb,Cr}. // Returns {c0[12], c1[12], c2[12]}. Constants only -- no DSPs. + // verilator coverage_off + // Only the build's OUTPUT_MODE arm is reachable (YUV vs RGB/RAW palette); + // the other arm is dead code in any single-mode build. Cross-mode + // correctness is the byte-exact all_modes model gate's job, not this + // single-config coverage sim. function [35:0] bar_palette; input [2:0] idx; begin @@ -418,7 +430,7 @@ module vtpgz_core #( endcase end else begin case (idx) - 3'd0: bar_palette = {12'hFFF, 12'hFFF, 12'hFFF}; + 3'd0: bar_palette = {12'hFFF, 12'hFFF, 12'hFFF}; // white 3'd1: bar_palette = {12'hFFF, 12'hFFF, 12'h000}; 3'd2: bar_palette = {12'h000, 12'hFFF, 12'hFFF}; 3'd3: bar_palette = {12'h000, 12'hFFF, 12'h000}; @@ -430,6 +442,7 @@ module vtpgz_core #( end end endfunction + // verilator coverage_on generate if (EN_COLORBAR) begin : g_colorbar reg [15:0] bar_pix_cnt; @@ -438,8 +451,8 @@ module vtpgz_core #( // as the base counter, one step per lane. bix_l[gl] is lane gl's bar // index; the base advances by NPPC steps per beat. At NPPC==1 this is // one step and reproduces the original recurrence exactly. - wire [15:0] bpc_l [0:NPPC]; - wire [2:0] bix_l [0:NPPC]; + wire [15:0] bpc_l [0:NPPC] /* verilator split_var */; + wire [2:0] bix_l [0:NPPC] /* verilator split_var */; assign bpc_l[0] = bar_pix_cnt; assign bix_l[0] = bar_idx; genvar cbl; @@ -492,7 +505,7 @@ module vtpgz_core #( // Per-lane accumulator chain: lane gl sees hg_acc + gl*step. The base // advances by NPPC*step per beat; at NPPC==1 this is one +step and is // identical to the original. - wire [19:0] hga_l [0:NPPC]; + wire [19:0] hga_l [0:NPPC] /* verilator split_var */; assign hga_l[0] = hg_acc; genvar hgl; for (hgl = 0; hgl < NPPC; hgl = hgl + 1) begin : g_hg_chain @@ -555,8 +568,8 @@ module vtpgz_core #( // "single-step" of lane l-1. cxc[NPPC]/cxs[NPPC] is the state after // all NPPC lanes = the base for the next beat. At NPPC==1 this is a // single step and collapses to the original recurrence exactly. - wire [15:0] cxc [0:NPPC]; - wire cxs [0:NPPC]; + wire [15:0] cxc [0:NPPC] /* verilator split_var */; + wire cxs [0:NPPC] /* verilator split_var */; assign cxc[0] = chk_x_cnt; assign cxs[0] = chk_sel_x; genvar gl; @@ -719,7 +732,7 @@ module vtpgz_core #( wire [15:0] grid_eff = (cfg_grid_spacing == 16'h0) ? 16'h1 : cfg_grid_spacing; reg [15:0] gx_cnt, gy_cnt; // base = lane 0 x-counter at pixel x // Per-lane x-axis chain (see checker for the recurrence rationale). - wire [15:0] gxc [0:NPPC]; + wire [15:0] gxc [0:NPPC] /* verilator split_var */; assign gxc[0] = gx_cnt; genvar gl; for (gl = 0; gl < NPPC; gl = gl + 1) begin : g_grid_chain @@ -774,7 +787,7 @@ module vtpgz_core #( generate if (EN_RAMP) begin : g_ramp reg [19:0] ramp_acc; // Per-lane accumulator chain (same structure as hgrad). - wire [19:0] rmp_l [0:NPPC]; + wire [19:0] rmp_l [0:NPPC] /* verilator split_var */; assign rmp_l[0] = ramp_acc; genvar rml; for (rml = 0; rml < NPPC; rml = rml + 1) begin : g_ramp_chain @@ -804,7 +817,7 @@ module vtpgz_core #( // At NPPC==1 this is a single step -- identical to the original. generate if (EN_NOISE) begin : g_noise reg [15:0] lfsr; - wire [15:0] lf_l [0:NPPC]; + wire [15:0] lf_l [0:NPPC] /* verilator split_var */; assign lf_l[0] = lfsr; genvar nl; for (nl = 0; nl < NPPC; nl = nl + 1) begin : g_noise_chain @@ -910,7 +923,7 @@ module vtpgz_core #( // NPPC==1 registered read's 1-px shift does not apply). Cost is // N image copies -- keep IMAGE_W/H modest for high PPC. reg [ACC_X_W-1:0] acc_x; // base = lane 0 acc at pixel x - wire [ACC_X_W-1:0] axc [0:NPPC]; + wire [ACC_X_W-1:0] axc [0:NPPC] /* verilator split_var */; assign axc[0] = acc_x; genvar il; for (il = 0; il < NPPC; il = il + 1) begin : g_img_lane @@ -1032,7 +1045,7 @@ module vtpgz_core #( end else begin : g_bimg_ppcN // ---- NPPC>1: N replicated memories, combinational shift-free ---- reg [BIMG_ACC_X_W-1:0] bimg_acc_x; // base = lane 0 acc at pixel x - wire [BIMG_ACC_X_W-1:0] bxc [0:NPPC]; + wire [BIMG_ACC_X_W-1:0] bxc [0:NPPC] /* verilator split_var */; assign bxc[0] = bimg_acc_x; genvar bil; for (bil = 0; bil < NPPC; bil = bil + 1) begin : g_bimg_lane @@ -1132,7 +1145,13 @@ module vtpgz_core #( // DCE) keeps the 1ppc build provably identical to prior releases. The // loop also starts at lane 1 -- lane 0 of the bus is always sourced from // the scalar mux -- so no lane's mux is duplicated at any NPPC. + // verilator coverage_off + // At NPPC==1 these per-lane pattern buses are tied to 0 (g_pat_lanes_off) + // and never toggle; they carry real data only at NPPC>1, which the + // coverage sim (PPC=1) does not build. PPC>1 is verified by the cocotb + // data-path suite and the iverilog beat-exact gate. wire [12*NPPC-1:0] pat_c0_bus, pat_c1_bus, pat_c2_bus; + // verilator coverage_on genvar gpl; generate if (NPPC > 1) begin : g_pat_lanes assign pat_c0_bus[11:0] = 12'h0; // lane 0 unused (scalar path drives it) @@ -1408,10 +1427,16 @@ module vtpgz_core #( // now a reusable function so each of the NPPC lanes packs independently. // xlsb/ylsb are the packed pixel's (x[0], y[0]) parity used by the // YUV422 chroma phase and the RAW Bayer 2x2 select. + // verilator coverage_off + // Only the build's OUTPUT_MODE / RGB_ORDER / RAW_BAYER arm is reachable; + // the other mode/order/bayer arms are dead code in any single-config + // build. All 20 mode/bpc configs are verified byte-exact by the + // all_modes model gate, so packing correctness is covered there. function [PIX_TDATA_WIDTH-1:0] pack_pixel; input [11:0] c0_12, c1_12, c2_12; input xlsb, ylsb; reg [BPC-1:0] c0, c1, c2, cc, raw_sel; + reg [PIX_TDATA_WIDTH-1:0] p; begin // Bit-shrink (BPC<=12: truncate LSBs) / grow (BPC>12: zero-extend). // Truncation to [BPC-1:0] happens on assignment; SHIFT_DN/SHIFT_UP @@ -1419,19 +1444,30 @@ module vtpgz_core #( c0 = (BPC <= 12) ? (c0_12 >> SHIFT_DN) : (c0_12 << SHIFT_UP); c1 = (BPC <= 12) ? (c1_12 >> SHIFT_DN) : (c1_12 << SHIFT_UP); c2 = (BPC <= 12) ? (c2_12 >> SHIFT_DN) : (c2_12 << SHIFT_UP); + // Assign the component concat to the full-width word (auto + // zero-extends for the Xilinx order = components in the LSBs) or + // left-shift it by a clamped pad (legacy order = components in the + // MSBs). This avoids both `{0{1'b0}}` zero-width replications + // (which crash Verilator 5.020) and out-of-range part selects in + // the non-native mode's dead branches. Width mismatches in dead + // branches are truncations only (covered by -Wno-WIDTH). if (OUTPUT_MODE == `VTPGZ_MODE_RGB || (OUTPUT_MODE == `VTPGZ_MODE_YUV && YUV_SUBSAMPLE == `VTPGZ_YUV_444)) begin - if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) - pack_pixel = {{(PIX_TDATA_WIDTH-3*BPC){1'b0}}, c2, c1, c0}; - else - pack_pixel = {c0, c1, c2, {(PIX_TDATA_WIDTH-3*BPC){1'b0}}}; + if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) begin + p = {c2, c1, c0}; + end else begin + p = {c0, c1, c2}; + p = p << PAD3; + end end else if (OUTPUT_MODE == `VTPGZ_MODE_YUV) begin // 4:2:2 -- {Y, C}; C = Cb on even-x, Cr on odd-x cc = (xlsb == 1'b0) ? c1 : c2; - if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) - pack_pixel = {{(PIX_TDATA_WIDTH-2*BPC){1'b0}}, cc, c0}; - else - pack_pixel = {c0, cc, {(PIX_TDATA_WIDTH-2*BPC){1'b0}}}; + if (RGB_ORDER == `VTPGZ_RGB_ORDER_XILINX) begin + p = {cc, c0}; + end else begin + p = {c0, cc}; + p = p << PAD2; + end end else begin // RAW: single component, RAW_BAYER selects the 2x2 mosaic. // PLAIN : monochrome, take G (c1) every pixel @@ -1448,10 +1484,12 @@ module vtpgz_core #( : ((xlsb==1'b0)?c0:c1); default: raw_sel = c1; // PLAIN endcase - pack_pixel = {{(PIX_TDATA_WIDTH-BPC){1'b0}}, raw_sel}; + p = raw_sel; // single component in LSBs, high bits zero end + pack_pixel = p; end endfunction + // verilator coverage_on /*verilator coverage_off*/ reg [C_AXIS_TDATA_WIDTH-1:0] tdata_r; /*verilator coverage_on*/ reg tvalid_r; From c20feafed434abe513fea47355c0f36f16afe098 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Sat, 1 Aug 2026 15:50:03 +0800 Subject: [PATCH 10/13] Fix multi-driven s1 pipeline registers at PPC>1 The stage-1 pipeline registers (pat_c*_s1, box_in_s1, box_img_*_s1, box_on_border_s1) were driven by two always blocks: the main FSM block wrote lane 0, a per-lane generate block wrote lanes 1..NPPC-1. Vivado treats each packed register as multi-driven (Synth 8-6858/8-6859), preserves the reset/GND driver for the upper lanes and drops the generate driver, zeroing lanes 1..N-1 in silicon. Simulators tolerate the non-overlapping slice writes, so this only surfaced on hardware. Collapse both into one always block with a constant-bound loop for the upper lanes. It unrolls to nothing at NPPC==1, so the 1ppc build stays byte-identical. Verified byte-exact on Arty A7-100T at PPC=4. --- rtl/vtpgz_core.v | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index 9dfb187..ece9ca8 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -1270,6 +1270,15 @@ module vtpgz_core #( reg pix_valid_s1, pix_sof_s1, pix_eol_s1, pix_eof_s1; reg pix_x_lsb_s1, pix_y_lsb_s1; wire pipe_advance; + // Single-driver latch: lane 0 from the scalar mux, lanes 1..NPPC-1 from + // the per-lane buses, all in ONE always block. A separate generate always + // block per lane (the prior structure) leaves each packed s1 register + // multi-driven; Vivado then preserves the reset/GND driver for the upper + // lanes and silently drops the per-lane driver (Synth 8-6858/8-6859), + // zeroing lanes 1..N-1 in silicon while sim tolerates the slice writes. + // The loop bound is a constant, so at NPPC==1 it unrolls to nothing and + // the 1ppc build stays byte-identical to prior releases. + integer li_s1; always @(posedge aclk) begin if (!aresetn) begin box_in_s1 <= {NPPC{1'b0}}; @@ -1302,35 +1311,19 @@ module vtpgz_core #( pix_eof_s1 <= pix_eof; pix_x_lsb_s1 <= x[0]; pix_y_lsb_s1 <= y[0]; - end - end - // lanes 1..NPPC-1 latch the per-lane buses (stripped when NPPC==1) - generate - genvar gs1; - for (gs1 = 1; gs1 < NPPC; gs1 = gs1 + 1) begin : g_s1_lane - always @(posedge aclk) begin - if (!aresetn) begin - box_in_s1[gs1] <= 1'b0; - box_on_border_s1[gs1] <= 1'b0; - pat_c0_s1[12*gs1 +: 12] <= 12'h0; - pat_c1_s1[12*gs1 +: 12] <= 12'h0; - pat_c2_s1[12*gs1 +: 12] <= 12'h0; - box_img_r_s1[12*gs1 +: 12] <= 12'h0; - box_img_g_s1[12*gs1 +: 12] <= 12'h0; - box_img_b_s1[12*gs1 +: 12] <= 12'h0; - end else if (pipe_advance) begin - box_in_s1[gs1] <= box_in_bus[gs1]; - box_on_border_s1[gs1] <= box_on_border_bus[gs1]; - pat_c0_s1[12*gs1 +: 12] <= pat_c0_bus[12*gs1 +: 12]; - pat_c1_s1[12*gs1 +: 12] <= pat_c1_bus[12*gs1 +: 12]; - pat_c2_s1[12*gs1 +: 12] <= pat_c2_bus[12*gs1 +: 12]; - box_img_r_s1[12*gs1 +: 12] <= box_img_r_bus[12*gs1 +: 12]; - box_img_g_s1[12*gs1 +: 12] <= box_img_g_bus[12*gs1 +: 12]; - box_img_b_s1[12*gs1 +: 12] <= box_img_b_bus[12*gs1 +: 12]; - end + // lanes 1..NPPC-1 latch the per-lane buses (unrolls away at NPPC==1) + for (li_s1 = 1; li_s1 < NPPC; li_s1 = li_s1 + 1) begin + box_in_s1[li_s1] <= box_in_bus[li_s1]; + box_on_border_s1[li_s1] <= box_on_border_bus[li_s1]; + pat_c0_s1[12*li_s1 +: 12] <= pat_c0_bus[12*li_s1 +: 12]; + pat_c1_s1[12*li_s1 +: 12] <= pat_c1_bus[12*li_s1 +: 12]; + pat_c2_s1[12*li_s1 +: 12] <= pat_c2_bus[12*li_s1 +: 12]; + box_img_r_s1[12*li_s1 +: 12] <= box_img_r_bus[12*li_s1 +: 12]; + box_img_g_s1[12*li_s1 +: 12] <= box_img_g_bus[12*li_s1 +: 12]; + box_img_b_s1[12*li_s1 +: 12] <= box_img_b_bus[12*li_s1 +: 12]; end end - endgenerate + end // When EN_BOX_IMAGE=1 the box interior shows the scaled image instead // of cfg_box_color -- but only while the host has programmed non-zero From 36b9109c2266ffd6d85ab5ba92923a436bd318b4 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Sat, 1 Aug 2026 15:50:19 +0800 Subject: [PATCH 11/13] Arty A7 demo: support PPC>1 capture and test Enable the Arty A7-100T demo and self-test to validate pixels-per-clock > 1 on silicon: - frame_capture: serialize each TDATA_WIDTH-bit beat into ceil(W/32) little-endian 32-bit BRAM words; WPB==1 path unchanged. - demo_top: PIXELS_PER_CLOCK localparam (now 4); TDATA_WIDTH derived from it; forward the parameter to the IP. - clk_gen: parameterize CLKOUT0_DIVIDE. PPC>1 builds run the demo slower (per-lane counter chains do not close 130 MHz); correctness, not throughput, is the goal there. - run_hw_test: read back PPC, expand expected words via render_frame_beats at the beat width, validate serialized capture. Verified byte-exact across 9 patterns at PPC=4. --- .gitignore | 2 +- hw/arty_a7_100t/python/run_hw_test.py | 26 ++++++---- hw/arty_a7_100t/rtl/clk_gen.v | 12 +++-- hw/arty_a7_100t/rtl/demo_top.v | 21 ++++++-- hw/arty_a7_100t/rtl/frame_capture.v | 71 ++++++++++++++++++++++----- 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 1b292b6..fd3ddf4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ synth/results/ synth/run_matrix.log hw/arty_a7_100t/build/ hw/arty_a7_100t/build.log -hw/arty_a7_100t/build_ppc1.log +hw/arty_a7_100t/build_ppc*.log hw/kv260/build/ .Xil/ vivado*.jou diff --git a/hw/arty_a7_100t/python/run_hw_test.py b/hw/arty_a7_100t/python/run_hw_test.py index d4bb3aa..6c8a3e1 100644 --- a/hw/arty_a7_100t/python/run_hw_test.py +++ b/hw/arty_a7_100t/python/run_hw_test.py @@ -50,7 +50,8 @@ from fcapz.transport import XilinxHwServerTransport # noqa: E402 from fcapz.ejtagaxi import EjtagAxiController, AXIError # noqa: E402 from vtpgz_model import ( # noqa: E402 - VtpgzConfig, VtpgzRegs, render_frame, tdata_to_bram_words, + VtpgzConfig, VtpgzRegs, render_frame, render_frame_beats, + tdata_to_bram_words, MODE_RGB, MODE_RAW, MODE_YUV, YUV_444, YUV_422, RAW_PLAIN, RAW_RGGB, RAW_BGGR, RAW_GRBG, RAW_GBRG, @@ -79,6 +80,7 @@ VTPGZ_IMG_HEIGHT = 0x14 VTPGZ_PATTERN_SEL = 0x18 VTPGZ_COLOR_FORMAT = 0x1C +VTPGZ_PIXELS_PER_CLOCK = 0x30 # RO: build-time PIXELS_PER_CLOCK (1/2/4/8) VTPGZ_SOLID_COLOR = 0x20 VTPGZ_BOX_COLOR = 0x24 VTPGZ_BOX_SIZE = 0x28 @@ -105,12 +107,12 @@ def cfg_for(pat: int, mode: int, bpc: int, sub: int, - bayer: int, order: int) -> VtpgzConfig: + bayer: int, order: int, ppc: int = 1) -> VtpgzConfig: return VtpgzConfig( width=WIDTH, height=HEIGHT, pattern=pat, output_mode=mode, yuv_subsample=sub, raw_bayer=bayer, - rgb_order=order, bpc=bpc, + rgb_order=order, bpc=bpc, pixels_per_clock=ppc, bar_width=WIDTH // 8, hg_step=0xFFF // (WIDTH - 1), vg_step=0xFFF // (HEIGHT - 1), @@ -170,8 +172,12 @@ def run_one(axi: EjtagAxiController, cfg: VtpgzConfig, axi.axi_write(VTPGZ_BASE + VTPGZ_CONTROL, 0) return False, f"timeout (status=0x{sts:08X}, words={word_count})" if verbose: print(f" after capture: status=0x{sts:08X} words={word_count}") - # 5. Burst-read the frame - expected_words = cfg.width * cfg.height + # 5. Render the reference beats, serialized to 32-bit BRAM words exactly as + # frame_capture stores them: ceil(beat_width/32) little-endian words/beat. + # At ppc=1 render_frame_beats == render_frame and beat_width == tdata_width, + # so this reduces to the classic one-word-per-pixel layout. + sw_words = tdata_to_bram_words(render_frame_beats(cfg), cfg.beat_tdata_width) + expected_words = len(sw_words) if word_count < expected_words: axi.axi_write(VTPGZ_BASE + VTPGZ_CONTROL, 0) return False, f"short frame: got {word_count} expected {expected_words}" @@ -182,8 +188,6 @@ def run_one(axi: EjtagAxiController, cfg: VtpgzConfig, except AXIError as e: axi.axi_write(VTPGZ_BASE + VTPGZ_CONTROL, 0) return False, f"read_block failed: {e}" - # 6. Render reference and compare - sw_words = tdata_to_bram_words(render_frame(cfg), cfg.tdata_width) # 7. Disable axi.axi_write(VTPGZ_BASE + VTPGZ_CONTROL, 0) @@ -275,8 +279,12 @@ def main() -> int: rb_order = (cf >> 6) & 0x1 rb_bpc = (cf >> 8) & 0xFF rb_tw = (cf >> 16) & 0xFFFF + rb_ppc = bridge.axi_read(VTPGZ_BASE + VTPGZ_PIXELS_PER_CLOCK) print(f"Build cfg: mode={rb_mode} sub={rb_sub} bayer={rb_bayer} " - f"order={rb_order} bpc={rb_bpc} tdata_width={rb_tw}") + f"order={rb_order} bpc={rb_bpc} tdata_width={rb_tw} ppc={rb_ppc}") + if rb_ppc not in (1, 2, 4, 8): + print(f"ERROR: bogus PIXELS_PER_CLOCK readback {rb_ppc}", file=sys.stderr) + return 2 # CLI overrides take precedence over the read-back, but warn the # user if the override actually disagrees with the loaded @@ -304,7 +312,7 @@ def _override(name: str, cli_val, rb_val, mapping=None): for pat in pats: n += 1 - cfg = cfg_for(pat, mode, bpc, sub, bayer, order) + cfg = cfg_for(pat, mode, bpc, sub, bayer, order, ppc=rb_ppc) ok, err = run_one(bridge, cfg, verbose=(args.only is not None)) tag = "OK " if ok else "FAIL" print(f" {tag} pat={pat}" + (f" {err}" if err else "")) diff --git a/hw/arty_a7_100t/rtl/clk_gen.v b/hw/arty_a7_100t/rtl/clk_gen.v index d35cc69..419a037 100644 --- a/hw/arty_a7_100t/rtl/clk_gen.v +++ b/hw/arty_a7_100t/rtl/clk_gen.v @@ -12,10 +12,16 @@ `timescale 1ns/1ps -module clk_gen ( +module clk_gen #( + // CLKOUT0 = 650 MHz / CLKOUT0_DIVIDE. Default 5.0 -> 130 MHz. Higher-PPC + // demo builds raise this (slower clock) because the per-lane counter-chain + // patterns (checker/grid) have a longer combinational path at PPC>1 and + // do not close 130 MHz; correctness, not throughput, is the goal there. + parameter real CLKOUT0_DIVIDE = 5.000 +)( input wire clk_in, // 100 MHz oscillator input wire reset_btn, // active-high external reset (BTN0) - output wire clk_out, // 130 MHz + output wire clk_out, // 650 MHz / CLKOUT0_DIVIDE output wire rst_n // active low, sync to clk_out ); @@ -28,7 +34,7 @@ module clk_gen ( .CLKFBOUT_MULT_F (13.000), // VCO = 100 * 13 / 2 = 650 MHz .CLKFBOUT_PHASE (0.000), .CLKIN1_PERIOD (10.000), // 100 MHz - .CLKOUT0_DIVIDE_F (5.000), // 650 / 5 = 130 MHz + .CLKOUT0_DIVIDE_F (CLKOUT0_DIVIDE), // 650 / DIV MHz (default 5 -> 130) .CLKOUT0_DUTY_CYCLE(0.500), .CLKOUT0_PHASE (0.000), .DIVCLK_DIVIDE (2), diff --git a/hw/arty_a7_100t/rtl/demo_top.v b/hw/arty_a7_100t/rtl/demo_top.v index 9ca13ab..e85761f 100644 --- a/hw/arty_a7_100t/rtl/demo_top.v +++ b/hw/arty_a7_100t/rtl/demo_top.v @@ -42,10 +42,18 @@ module demo_top ( output wire led3 ); + // Pixels-per-clock for this demo build (1/2/4/8). Declared here so the + // clock generator can slow down for PPC>1 (see DEMO_CLK_DIV below). + localparam VTPGZ_PIXELS_PER_CLOCK = 4; + // ---------------- clock & reset ---------------- + // PPC=1 runs 130 MHz (650/5). At PPC>1 the per-lane counter-chain patterns + // (checker/grid) don't close 130 MHz, so slow to 50 MHz (650/13) -- this + // demo validates PPC correctness on silicon, not maximum throughput. + localparam real DEMO_CLK_DIV = (VTPGZ_PIXELS_PER_CLOCK > 1) ? 13.000 : 5.000; wire clk; wire rst_n; - clk_gen u_clkgen ( + clk_gen #(.CLKOUT0_DIVIDE(DEMO_CLK_DIV)) u_clkgen ( .clk_in (CLK100MHZ), .reset_btn (btn0), .clk_out (clk), @@ -230,12 +238,16 @@ module demo_top ( localparam VTPGZ_YUV_SUBSAMPLE = 0; localparam VTPGZ_RAW_BAYER = 1; localparam VTPGZ_RGB_ORDER = 0; // 0=Xilinx, 1=legacy - // Match vtpgz_axilite_top's auto-derived TDATA_WIDTH formula - localparam VTPGZ_TDATA_WIDTH = + // VTPGZ_PIXELS_PER_CLOCK is declared near the clock generator above. + // Match vtpgz_axilite_top's auto-derived TDATA_WIDTH formula (per pixel), + // then widen by PIXELS_PER_CLOCK for the packed beat. frame_capture + // serializes each wide beat into ceil(width/32) 32-bit BRAM words. + localparam VTPGZ_PIX_TDATA_WIDTH = (VTPGZ_OUTPUT_MODE == 0) ? (((3*VTPGZ_BPC + 7) / 8) * 8) : (VTPGZ_OUTPUT_MODE == 1) ? ((( VTPGZ_BPC + 7) / 8) * 8) : (VTPGZ_YUV_SUBSAMPLE == 0 ? (((3*VTPGZ_BPC + 7) / 8) * 8) : (((2*VTPGZ_BPC + 7) / 8) * 8)); + localparam VTPGZ_TDATA_WIDTH = VTPGZ_PIXELS_PER_CLOCK * VTPGZ_PIX_TDATA_WIDTH; wire [VTPGZ_TDATA_WIDTH-1:0] vtpgz_axis_tdata; wire vtpgz_axis_tvalid; @@ -250,7 +262,8 @@ module demo_top ( .YUV_SUBSAMPLE(VTPGZ_YUV_SUBSAMPLE), .RAW_BAYER (VTPGZ_RAW_BAYER), .RGB_ORDER (VTPGZ_RGB_ORDER), - .BPC (VTPGZ_BPC) + .BPC (VTPGZ_BPC), + .PIXELS_PER_CLOCK(VTPGZ_PIXELS_PER_CLOCK) ) u_vtpgz ( .aclk (clk), .aresetn (rst_n), diff --git a/hw/arty_a7_100t/rtl/frame_capture.v b/hw/arty_a7_100t/rtl/frame_capture.v index 0f58079..89d8d68 100644 --- a/hw/arty_a7_100t/rtl/frame_capture.v +++ b/hw/arty_a7_100t/rtl/frame_capture.v @@ -106,20 +106,39 @@ module frame_capture #( // a previous frame even before saw_sof. Once saw_sof is set, the beats // start landing in BRAM. After the second tuser the FSM exits and tready // drops. - assign s_axis_tready = capturing && !bram_full; - wire stream_beat = s_axis_tvalid && s_axis_tready; - // BRAM stores the LOW 32 bits of each tdata beat (zero-extended if - // TDATA_WIDTH < 32). For wider tdata the upper bits are dropped — bump - // BRAM to 64 bits if you need to capture wider streams. - wire [31:0] beat_word; + // ---- beat serialization ---- + // Each captured AXIS beat is TDATA_WIDTH wide; the BRAM is 32-bit. Split + // every beat into WPB = ceil(TDATA_WIDTH/32) little-endian 32-bit words + // (low word first), which is exactly what the host's tdata_to_bram_words() + // expects. At WPB==1 (TDATA_WIDTH<=32, e.g. 1 ppc) this collapses to the + // original single-word write and tready is unchanged. + localparam integer WPB = (TDATA_WIDTH + 31) / 32; + // Zero-extend the beat to a whole number of 32-bit words for clean slicing. + wire [32*WPB-1:0] tdata_ext; generate - if (TDATA_WIDTH >= 32) begin : g_tdata_ge32 - assign beat_word = s_axis_tdata[31:0]; - end else begin : g_tdata_lt32 - assign beat_word = {{(32-TDATA_WIDTH){1'b0}}, s_axis_tdata}; + if (32*WPB == TDATA_WIDTH) begin : g_ext_exact + assign tdata_ext = s_axis_tdata; + end else begin : g_ext_pad + assign tdata_ext = {{(32*WPB - TDATA_WIDTH){1'b0}}, s_axis_tdata}; end endgenerate + reg serializing; // emitting tail words 1..WPB-1 of a beat + reg [15:0] sub_cnt; // index of the sub-word being written + reg [32*WPB-1:0] beat_hold; // beat latched during serialization + + // While serializing the tail words of a beat, drop tready so the source + // holds the next beat until the current one is fully stored. + generate + if (WPB > 1) begin : g_tready_ser + assign s_axis_tready = capturing && !bram_full && !serializing; + end else begin : g_tready_simple + assign s_axis_tready = capturing && !bram_full; + end + endgenerate + wire stream_beat = s_axis_tvalid && s_axis_tready; + wire [31:0] beat_word = tdata_ext[31:0]; // word 0 (low 32 bits) + // Capture FSM // armed -> wait for first tuser (SOF), latch saw_sof // armed && saw_sof -> stream beats into BRAM @@ -132,6 +151,8 @@ module frame_capture #( capturing <= 1'b0; saw_sof <= 1'b0; wr_idx <= {(DEPTH_LOG2+1){1'b0}}; + serializing <= 1'b0; + sub_cnt <= 16'h0; end else if (csr_clear_pulse) begin // CSR clear has the HIGHEST priority and overrides everything // else this cycle. Without this priority, a simultaneous @@ -141,6 +162,8 @@ module frame_capture #( capturing <= 1'b0; saw_sof <= 1'b0; wr_idx <= {(DEPTH_LOG2+1){1'b0}}; + serializing <= 1'b0; + sub_cnt <= 16'h0; end else begin // CSR arm latches the request if (csr_arm_pulse) begin @@ -184,6 +207,21 @@ module frame_capture #( end // else: pre-SOF beats (drained but not stored) end + // Beat serialization (WPB>1): word 0 of an accepted beat is + // written/counted by the stream_beat logic above; emit the + // remaining WPB-1 words on the following cycles with tready low. + if (WPB > 1) begin + if (serializing) begin + wr_idx <= wr_idx + 1'b1; + sub_cnt <= sub_cnt + 16'd1; + if (sub_cnt == WPB[15:0] - 16'd1) + serializing <= 1'b0; + end else if (valid_capture_beat) begin + beat_hold <= tdata_ext; + serializing <= 1'b1; + sub_cnt <= 16'd1; + end + end end end @@ -195,8 +233,17 @@ module frame_capture #( (saw_sof && !s_axis_tuser) // mid-frame ); assign bram_wr_addr_w = wr_idx[DEPTH_LOG2-1:0]; - assign bram_din_w = beat_word; - assign bram_we_w = valid_capture_beat; + generate + if (WPB > 1) begin : g_wr_ser + // Word 0 on the accept cycle, tail words while serializing. + assign bram_din_w = serializing ? beat_hold[32*sub_cnt +: 32] + : beat_word; + assign bram_we_w = valid_capture_beat || serializing; + end else begin : g_wr_simple + assign bram_din_w = beat_word; + assign bram_we_w = valid_capture_beat; + end + endgenerate bram_sdp #(.WIDTH(32), .DEPTH(DEPTH)) u_bram ( .clk (aclk), From 1370fec8b0038de73979e3d2d5d6b233d682f893 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Sat, 1 Aug 2026 15:51:47 +0800 Subject: [PATCH 12/13] Document PPC>1 hardware validation on Arty A7 Note how to build/run the demo at pixels-per-clock > 1: set VTPGZ_PIXELS_PER_CLOCK in demo_top.v, rebuild, re-run. frame_capture serializes wide beats and run_hw_test checks byte-exact. PPC>1 uses a lower demo clock (per-lane chains do not close 130 MHz). PPC=4 verified byte-exact on the board. --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 6dc5cda..87f6a39 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,17 @@ Expected: `Ran 108 combinations, 0 failures` / `HW PASS - byte-exact across all Architecture and address map are documented in [hw/arty_a7_100t/README.md](hw/arty_a7_100t/README.md). +**Pixels-per-clock on silicon.** The demo builds at `PIXELS_PER_CLOCK=1` +by default. To validate packed-pixel output on hardware, set +`VTPGZ_PIXELS_PER_CLOCK` in [demo_top.v](hw/arty_a7_100t/rtl/demo_top.v) +(2/4/8), rebuild, and re-run — `frame_capture` serializes each wide beat +into `ceil(TDATA_WIDTH/32)` little-endian words and `run_hw_test.py` +reads back the configured PPC and checks byte-exact. PPC>1 builds run the +demo at a lower clock (`clk_gen`'s `CLKOUT0_DIVIDE`): the per-lane +counter-chain patterns (checker/grid) do not close 130 MHz, and the demo +targets correctness rather than throughput. PPC=4 is verified byte-exact +across all patterns on the board. + [↑ back to top](#index) From 5322140dbc44a51f7b7c3c609cd14695430be1e6 Mon Sep 17 00:00:00 2001 From: Leonardo Capossio Date: Sat, 1 Aug 2026 16:08:13 +0800 Subject: [PATCH 13/13] Exclude PPC>1 s1 latch loop from coverage gate The single-block s1 latch loop added for the multi-driver fix has zero iterations at NPPC==1, but unlike the old generate block it is not elaborated away, so its body counted as uncovered lines in the PPC=1 coverage sim (98%, 622/631). Wrap it in verilator coverage_off, matching the existing PPC>1-only pat_c*_bus logic. Coverage back to 100%; PPC>1 is covered by the cocotb data-path suite and the iverilog beat-exact gate. --- rtl/vtpgz_core.v | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rtl/vtpgz_core.v b/rtl/vtpgz_core.v index ece9ca8..74ded68 100644 --- a/rtl/vtpgz_core.v +++ b/rtl/vtpgz_core.v @@ -1311,7 +1311,12 @@ module vtpgz_core #( pix_eof_s1 <= pix_eof; pix_x_lsb_s1 <= x[0]; pix_y_lsb_s1 <= y[0]; - // lanes 1..NPPC-1 latch the per-lane buses (unrolls away at NPPC==1) + // lanes 1..NPPC-1 latch the per-lane buses. This behavioural loop + // has zero iterations at NPPC==1 (unlike the old generate block it + // is not elaborated away), so its body is dead in the PPC=1 + // coverage sim -- exclude it, like the PPC>1-only pat_c*_bus logic. + // PPC>1 is covered by the cocotb data-path suite and iverilog gate. + // verilator coverage_off for (li_s1 = 1; li_s1 < NPPC; li_s1 = li_s1 + 1) begin box_in_s1[li_s1] <= box_in_bus[li_s1]; box_on_border_s1[li_s1] <= box_on_border_bus[li_s1]; @@ -1322,6 +1327,7 @@ module vtpgz_core #( box_img_g_s1[12*li_s1 +: 12] <= box_img_g_bus[12*li_s1 +: 12]; box_img_b_s1[12*li_s1 +: 12] <= box_img_b_bus[12*li_s1 +: 12]; end + // verilator coverage_on end end