From e67368d6215217956578456a1c95151918abb5ee Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:51:30 +0545 Subject: [PATCH] ft2232h: add frame drop counter (AUDIT-C12) + cfar RMW cadence guard (AUDIT-S22) AUDIT-C12: usb_data_interface_ft2232h had a misleading single-buffer comment that overstated the timing slack and referenced a frame_ack_toggle CDC that was never implemented. Re-verified actual numbers: at 178 fps the slack is 1.14 ms (20%), not "much shorter than gap". No data corruption today (write order matches read order, addresses don't collide), but frame_complete firing while WR_FSM is still draining the previous frame causes silent frame drops via the missed frame_ready_toggle edge. Fix is instrumentation, not architectural rework: add wr_done_toggle (ft_clk -> clk CDC) on WR_DONE -> WR_IDLE, track frame_pending in clk domain, count drops in 7-bit saturating frame_drop_count, surface in unused upper 7 bits of status_words[5]. Host now has visibility into the failure mode if margin ever shrinks (faster frame rate or USB bandwidth shortfall). Replaced misleading comment with corrected timing breakdown. AUDIT-S22: cfar_ca emits one detection per 3 cycles (THR/MUL/CMP); the detection RMW takes 3 cycles. Match by construction today, fragile against any CFAR speedup. Added a header comment in cfar_ca.v documenting the dependency, and a SIMULATION-only assertion in usb_data_interface_ft2232h.v that fires [ASSERT FAIL] AUDIT-S22 if cfar_valid arrives while RMW busy. Catches silent-drop regressions in the test suite. Verification: new tb_ft2232h_frame_drop.v with 5 scenarios (no drops / stalled drops / multi-drop / recovery / saturation at 127) - 10/10 PASS. Quick regression 31/31 PASS (was 30/30; +1 new test, 0 regressions). --- 9_Firmware/9_2_FPGA/cfar_ca.v | 15 + 9_Firmware/9_2_FPGA/run_regression.sh | 4 + .../9_2_FPGA/tb/tb_ft2232h_frame_drop.v | 283 ++++++++++++++++++ .../9_2_FPGA/usb_data_interface_ft2232h.v | 131 ++++++-- 4 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v diff --git a/9_Firmware/9_2_FPGA/cfar_ca.v b/9_Firmware/9_2_FPGA/cfar_ca.v index 66a9655..3d59278 100644 --- a/9_Firmware/9_2_FPGA/cfar_ca.v +++ b/9_Firmware/9_2_FPGA/cfar_ca.v @@ -51,6 +51,21 @@ * = 0.55 ms. Frame period @ PRF=1932 Hz, 32 chirps = 16.6 ms. Fits easily. * (3 cycles per CUT due to pipeline: THR → MUL → CMP) * + * AUDIT-S22 — DOWNSTREAM CADENCE DEPENDENCY (DO NOT BREAK): + * detect_valid pulses every 3rd cycle (one per CUT triplet). The downstream + * consumer usb_data_interface_ft2232h.v runs a 3-cycle read-modify-write + * on the detection-flag BRAM (idle → read-wait → write-back) and silently + * drops cfar_valid arriving while RMW is busy. The two cadences match + * today by construction. + * + * If you optimize this pipeline below 3 cycles per CUT (e.g., merging + * ST_CFAR_MUL+CMP into a single state, or feeding the comparator + * combinationally), you MUST also pipeline the RMW in + * usb_data_interface_ft2232h.v to keep up — otherwise every Nth + * detection is silently lost. A SIMULATION-only assertion in that + * module fires `[ASSERT FAIL] AUDIT-S22: cfar_valid arrived while RMW + * busy` to catch this regression in the test suite. + * * Resources: * - 1 BRAM36K for magnitude buffer (16384 x 17 bits) * - 1 DSP48 for alpha multiply diff --git a/9_Firmware/9_2_FPGA/run_regression.sh b/9_Firmware/9_2_FPGA/run_regression.sh index 0b69ba5..20a3e1e 100755 --- a/9_Firmware/9_2_FPGA/run_regression.sh +++ b/9_Firmware/9_2_FPGA/run_regression.sh @@ -550,6 +550,10 @@ run_test "FFT AXI Bridge tready handshake (AUDIT-C10)" \ tb/tb_fft_engine_axi_bridge.vvp \ tb/tb_fft_engine_axi_bridge.v fft_engine_axi_bridge.v +run_test "FT2232H Frame Drop Counter (AUDIT-C12)" \ + tb/tb_ft2232h_frame_drop.vvp \ + tb/tb_ft2232h_frame_drop.v usb_data_interface_ft2232h.v + echo "" # =========================================================================== diff --git a/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v b/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v new file mode 100644 index 0000000..3eaf0a4 --- /dev/null +++ b/9_Firmware/9_2_FPGA/tb/tb_ft2232h_frame_drop.v @@ -0,0 +1,283 @@ +`timescale 1ns / 1ps +`include "radar_params.vh" +// ============================================================================ +// tb_ft2232h_frame_drop.v — verifies AUDIT-C12 frame_drop_count instrumentation +// ============================================================================ +// The bridge logic added under AUDIT-C12 surfaces silent USB frame drops via +// a 7-bit `frame_drop_count` register exposed in `status_words[5][31:25]`. +// Tracking handshake: +// - clk-domain frame_pending: SET on frame_complete, CLEARED by wr_done_pulse +// - ft_clk-domain wr_done_toggle: flips on WR_DONE → WR_IDLE (frame fully sent) +// - 3-stage CDC syncs wr_done_toggle into clk for edge-detect +// +// Test cases: +// 1. Single frame, USB drains promptly → drop count stays 0 +// 2. Two back-to-back frame_complete with USB stalled → drop count = 1 +// 3. Multiple drops while stalled → drop count saturates at 127 +// 4. Stalled + recovery → drop count stable, frame_pending clears post-drain +// +// Stimulus uses `stream_control = 6'b001_000` (mag_only=1, no sections enabled) +// so the WR FSM goes HDR (8B) → FOOTER (1B) → DONE in 9 ft_clk cycles. This +// gives a fast, deterministic per-frame transfer time. AUDIT-C9 sim assertion +// is satisfied (mag_only=1, sparse_det=0). +// +// PASS criteria: +// - frame_drop_count matches expected value after each scenario +// - frame_pending tracks correctly across handshake +// - wr_done_toggle observed to flip on each successful drain +// ============================================================================ + +module tb_ft2232h_frame_drop; + localparam CLK_PER = 10.0; // 100 MHz + localparam FT_CLK_PER = 16.667; // 60 MHz + + reg clk = 1'b0; + reg ft_clk = 1'b0; + reg reset_n = 1'b0; + reg ft_reset_n = 1'b0; + + // Radar data inputs (clk domain) - all idle for this test + reg [31:0] range_profile = 32'd0; + reg range_valid = 1'b0; + reg [15:0] doppler_real = 16'd0; + reg [15:0] doppler_imag = 16'd0; + reg doppler_valid = 1'b0; + reg cfar_detection = 1'b0; + reg cfar_valid = 1'b0; + + reg [`RP_RANGE_BIN_WIDTH_MAX-1:0] range_bin_in = 0; + reg [4:0] doppler_bin_in = 5'd0; + reg frame_complete = 1'b0; + + // FT2232H interface (ft_clk domain) + wire [7:0] ft_data; + reg ft_rxf_n = 1'b1; // No host read commands + reg ft_txe_n = 1'b0; // 0 = USB ready (default) + wire ft_rd_n; + wire ft_wr_n; + wire ft_oe_n; + wire ft_siwu; + + wire [31:0] cmd_data; + wire cmd_valid; + wire [7:0] cmd_opcode; + wire [7:0] cmd_addr; + wire [15:0] cmd_value; + + // mag_only=1, sparse_det=0, all sections disabled (skip range/doppler/cfar) + // → WR FSM: HDR → FOOTER → DONE = fast deterministic drain + reg [5:0] stream_control = 6'b001_000; + + // Status inputs (irrelevant for this test) + reg status_request = 1'b0; + reg [15:0] status_cfar_threshold = 16'd0; + reg [5:0] status_stream_ctrl = 6'b001_000; + reg [1:0] status_radar_mode = 2'd0; + reg [15:0] status_long_chirp = 16'd0; + reg [15:0] status_long_listen = 16'd0; + reg [15:0] status_guard = 16'd0; + reg [15:0] status_short_chirp = 16'd0; + reg [15:0] status_short_listen = 16'd0; + reg [5:0] status_chirps_per_elev = 6'd0; + reg [1:0] status_range_mode = 2'd0; + reg status_chirps_mismatch = 1'b0; + reg [4:0] status_self_test_flags = 5'd0; + reg [7:0] status_self_test_detail = 8'd0; + reg status_self_test_busy = 1'b0; + reg [3:0] status_agc_current_gain = 4'd0; + reg [7:0] status_agc_peak_magnitude = 8'd0; + reg [7:0] status_agc_saturation_count = 8'd0; + reg status_agc_enable = 1'b0; + + integer pass = 0; + integer fail = 0; + + always #(CLK_PER/2) clk = ~clk; + always #(FT_CLK_PER/2) ft_clk = ~ft_clk; + + usb_data_interface_ft2232h u_dut ( + .clk(clk), + .reset_n(reset_n), + .ft_reset_n(ft_reset_n), + .range_profile(range_profile), + .range_valid(range_valid), + .doppler_real(doppler_real), + .doppler_imag(doppler_imag), + .doppler_valid(doppler_valid), + .cfar_detection(cfar_detection), + .cfar_valid(cfar_valid), + .range_bin_in(range_bin_in), + .doppler_bin_in(doppler_bin_in), + .frame_complete(frame_complete), + .ft_data(ft_data), + .ft_rxf_n(ft_rxf_n), + .ft_txe_n(ft_txe_n), + .ft_rd_n(ft_rd_n), + .ft_wr_n(ft_wr_n), + .ft_oe_n(ft_oe_n), + .ft_siwu(ft_siwu), + .ft_clk(ft_clk), + .cmd_data(cmd_data), + .cmd_valid(cmd_valid), + .cmd_opcode(cmd_opcode), + .cmd_addr(cmd_addr), + .cmd_value(cmd_value), + .stream_control(stream_control), + .status_request(status_request), + .status_cfar_threshold(status_cfar_threshold), + .status_stream_ctrl(status_stream_ctrl), + .status_radar_mode(status_radar_mode), + .status_long_chirp(status_long_chirp), + .status_long_listen(status_long_listen), + .status_guard(status_guard), + .status_short_chirp(status_short_chirp), + .status_short_listen(status_short_listen), + .status_chirps_per_elev(status_chirps_per_elev), + .status_range_mode(status_range_mode), + .status_chirps_mismatch(status_chirps_mismatch), + .status_self_test_flags(status_self_test_flags), + .status_self_test_detail(status_self_test_detail), + .status_self_test_busy(status_self_test_busy), + .status_agc_current_gain(status_agc_current_gain), + .status_agc_peak_magnitude(status_agc_peak_magnitude), + .status_agc_saturation_count(status_agc_saturation_count), + .status_agc_enable(status_agc_enable) + ); + + task pulse_frame_complete; + begin + @(posedge clk); #1; + frame_complete = 1'b1; + @(posedge clk); #1; + frame_complete = 1'b0; + end + endtask + + task wait_cycles; + input integer n; + integer i; + begin + for (i = 0; i < n; i = i + 1) @(posedge clk); + end + endtask + + task check; + input integer test_id; + input [127:0] label; + input integer expected; + input integer actual; + begin + if (expected == actual) begin + $display("[PASS] Test %0d (%0s): %0d == %0d", + test_id, label, actual, expected); + pass = pass + 1; + end else begin + $display("[FAIL] Test %0d (%0s): got %0d, expected %0d", + test_id, label, actual, expected); + fail = fail + 1; + end + end + endtask + + initial begin + $display("==========================================================="); + $display("tb_ft2232h_frame_drop — AUDIT-C12 frame_drop_count regression"); + $display("==========================================================="); + + // Reset both domains + repeat (4) @(posedge clk); + reset_n = 1'b1; + ft_reset_n = 1'b1; + wait_cycles(20); + + // ----------------------------------------------------------- + // Test 1: Normal flow. ft_txe_n=0 (USB ready). + // Single frame_complete; expect drain to complete and drop count=0. + // ----------------------------------------------------------- + $display("\n[TEST 1] Single frame, USB ready -> no drops"); + ft_txe_n = 1'b0; + pulse_frame_complete(); + // Wait for frame to drain through WR_FSM. With mag_only mode and + // stream_control[2:0]=000, FSM goes HDR (8B) -> FOOTER (1B) -> DONE. + // Each byte = 1 ft_clk cycle. Plus CDC latency. Allow ~50 ft_clk + // = ~833 ns = ~83 clk cycles. Be generous: wait 200 clk cycles. + wait_cycles(200); + check(1, "drop_count", 0, u_dut.frame_drop_count); + check(1, "frame_pending_cleared", 0, u_dut.frame_pending); + + // ----------------------------------------------------------- + // Test 2: USB stalled, two frame_completes back-to-back. + // Expect drop_count = 1 (second pulse arrives while pending). + // ----------------------------------------------------------- + $display("\n[TEST 2] USB stalled, 2 frame_completes -> drop count = 1"); + ft_txe_n = 1'b1; // Stall USB + wait_cycles(10); + pulse_frame_complete(); // frame N: pending=1, no drop + wait_cycles(20); // give time for any spurious wr_done_pulse + pulse_frame_complete(); // frame N+1: pending was 1, drop count++ + wait_cycles(20); + check(2, "drop_count_after_stall", 1, u_dut.frame_drop_count); + check(2, "frame_pending_still_set", 1, u_dut.frame_pending); + + // ----------------------------------------------------------- + // Test 3: Multiple drops while stalled. + // 3 more frame_completes -> drop count = 4 (1 from test 2 + 3 new). + // ----------------------------------------------------------- + $display("\n[TEST 3] 3 more frames during stall -> drop count = 4"); + pulse_frame_complete(); wait_cycles(15); + pulse_frame_complete(); wait_cycles(15); + pulse_frame_complete(); wait_cycles(15); + check(3, "drop_count_after_3_more", 4, u_dut.frame_drop_count); + + // ----------------------------------------------------------- + // Test 4: Recovery. Release USB, FSM drains, pending clears. + // Then a new frame_complete should NOT increment drop count. + // ----------------------------------------------------------- + $display("\n[TEST 4] USB recovers, drain completes, no new drop"); + ft_txe_n = 1'b0; // USB ready + wait_cycles(200); // Allow drain + check(4, "frame_pending_cleared_after_drain", 0, u_dut.frame_pending); + check(4, "drop_count_stable_after_drain", 4, u_dut.frame_drop_count); + // Now a clean frame_complete should add no drop + pulse_frame_complete(); + wait_cycles(200); + check(4, "drop_count_unchanged_clean_frame", 4, u_dut.frame_drop_count); + check(4, "frame_pending_cleared_post_clean", 0, u_dut.frame_pending); + + // ----------------------------------------------------------- + // Test 5: Saturation at 127. Stall USB and pulse frame_complete + // many times. drop_count should saturate, not wrap. + // ----------------------------------------------------------- + $display("\n[TEST 5] Saturation: 200 drops requested -> count saturates at 127"); + ft_txe_n = 1'b1; + // First make pending=1 (this isn't a drop) + pulse_frame_complete(); wait_cycles(10); + // Now 199 more frame_completes — each is a drop after the first counted earlier + // We're at drop_count=4 from prior tests, plus this new sequence will drive it up. + // After ~130 more pulses, should saturate at 127. + begin: sat_loop + integer k; + for (k = 0; k < 200; k = k + 1) begin + pulse_frame_complete(); + wait_cycles(5); + end + end + check(5, "drop_count_saturated", 127, u_dut.frame_drop_count); + + $display("\n-----------------------------------------------------------"); + $display("RESULTS: %0d PASS, %0d FAIL", pass, fail); + $display("-----------------------------------------------------------"); + if (fail == 0) + $display("[OVERALL PASS]"); + else + $display("[OVERALL FAIL]"); + $finish; + end + + initial begin + #(CLK_PER * 50000); + $display("[FATAL] Global timeout"); + $finish; + end + +endmodule diff --git a/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v b/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v index 0a678bf..f006f45 100644 --- a/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v +++ b/9_Firmware/9_2_FPGA/usb_data_interface_ft2232h.v @@ -204,6 +204,12 @@ localparam [3:0] WR_IDLE = 4'd0, reg [3:0] wr_state; +// AUDIT-C12 instrumentation: ft_clk → clk handshake. Toggles when WR_FSM +// completes a successful frame transfer (WR_DONE → WR_IDLE). Lets the clk +// domain detect frame drops (frame_complete arrives while previous transfer +// is still in flight). See full analysis in the clk-domain block below. +reg wr_done_toggle; + // ============================================================================ // READ FSM STATES (Host → FPGA, ft_clk domain — unchanged from legacy) // ============================================================================ @@ -484,25 +490,76 @@ always @(posedge clk or negedge reset_n) begin range_write_counter <= {RANGE_BIN_BITS{1'b0}}; end - // === Resume filling after ft_clk domain acknowledges frame transfer === - // (handled below via frame_ack_toggle CDC) - // For simplicity, we resume filling immediately — the USB transfer reads - // from the same BRAM while new data writes. Since the Doppler burst - // (0.164 ms) is much shorter than the inter-frame gap (5.6 ms), and USB - // transfer (0.875 ms) also fits within the gap, there's no overlap: - // t=0: frame_complete pulse (1 clk cycle, from edge detector) - // t=0-0.87ms: USB reads BRAM (old frame data is stable) - // t=5.6ms: next Doppler burst starts writing - // So single-buffer is safe. Re-enable filling after a short delay. + // === Resume filling after frame_complete === + // AUDIT-C12 timing analysis (corrected from earlier comment): + // - Frame period (178 fps): 5.62 ms + // - Doppler emit window: ~0.5 ms at end of frame (16384 cells × 1 + // emission/cycle + per-range-bin 16-pt FFT compute, ~50K cycles) + // - USB transfer (Hi-Speed bulk @ 8 MB/s): 35849 bytes / 8 MB/s + // = 4.48 ms (NOT 0.875 ms as the original comment claimed) + // - Slack at 178 fps: 5.62 − 4.48 = 1.14 ms (~20%) + // + // Write/read order BOTH advance range_bin slowest, doppler_bin fastest; + // FPGA write of frame N+1 starts at addr 0 while USB read of frame N is + // near addr 16383, so the brief overlap (~0.16 ms at end of WR_DOPPLER) + // never collides on the same address. No data corruption today. + // + // Real failure mode: at higher frame rates (or USB bandwidth shortfalls), + // frame_complete N+1 may fire while WR_FSM is still draining frame N. + // frame_ready_toggle's edge in the ft_clk domain is missed unless WR_FSM + // is in WR_IDLE — frame N+1 silently dropped. The frame_drop_count + // counter below makes this loud. + // + // Filling continues immediately so the next frame's data is captured + // (it goes to the same BRAM, possibly stomping on stale frame N data + // — but stale-stomp is fine: USB has either read those addresses + // already, or it hasn't and will read frame N+1's data at those + // addresses, which is what the host wants when frames drop). if (!frame_filling && !frame_complete) begin - // Re-enable after 1 cycle (frame_complete already deasserted) - frame_filling <= 1'b1; - detect_clearing <= 1'b1; // Clear detection BRAM for next frame + frame_filling <= 1'b1; + detect_clearing <= 1'b1; // Clear detection BRAM for next frame detect_clear_addr <= 14'd0; end end end +// ============================================================================ +// AUDIT-C12: frame_pending + frame_drop_count (clk domain) +// ============================================================================ +// Tracks whether a frame is queued for USB transfer and counts dropped frames +// (frame_complete fires while previous frame still in WR_FSM transit). The +// counter is exposed in status_words[5] for host visibility. +// +// CDC: wr_done_toggle (ft_clk) flips on every WR_DONE → WR_IDLE; we 3-stage +// sync into clk and edge-detect to clear frame_pending. +// ============================================================================ +reg frame_pending; +reg [6:0] frame_drop_count; // 7-bit, saturates at 127 + +(* ASYNC_REG = "TRUE" *) reg [2:0] wr_done_sync; +reg wr_done_prev; +wire wr_done_pulse = wr_done_sync[2] ^ wr_done_prev; + +always @(posedge clk or negedge reset_n) begin + if (!reset_n) begin + frame_pending <= 1'b0; + frame_drop_count <= 7'd0; + wr_done_sync <= 3'b000; + wr_done_prev <= 1'b0; + end else begin + wr_done_sync <= {wr_done_sync[1:0], wr_done_toggle}; + wr_done_prev <= wr_done_sync[2]; + + if (frame_complete) begin + if (frame_pending && frame_drop_count != 7'd127) + frame_drop_count <= frame_drop_count + 7'd1; + frame_pending <= 1'b1; + end else if (wr_done_pulse) begin + frame_pending <= 1'b0; + end + end +end + // ============================================================================ // TOGGLE CDC: clk (100 MHz) → ft_clk (60 MHz) // ============================================================================ @@ -533,6 +590,10 @@ wire status_req_ft = status_toggle_sync[2] ^ status_toggle_prev; (* ASYNC_REG = "TRUE" *) reg [5:0] stream_ctrl_sync_0; (* ASYNC_REG = "TRUE" *) reg [5:0] stream_ctrl_sync_1; +// --- AUDIT-C12: frame_drop_count CDC (slow-changing 7-bit value, 2-stage sync) --- +(* ASYNC_REG = "TRUE" *) reg [6:0] frame_drop_sync_0; +reg [6:0] frame_drop_sync_1; + wire stream_range_en = stream_ctrl_sync_1[0]; wire stream_doppler_en = stream_ctrl_sync_1[1]; wire stream_cfar_en = stream_ctrl_sync_1[2]; @@ -645,6 +706,8 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin status_toggle_prev <= 1'b0; stream_ctrl_sync_0 <= `RP_STREAM_CTRL_DEFAULT; stream_ctrl_sync_1 <= `RP_STREAM_CTRL_DEFAULT; + frame_drop_sync_0 <= 7'd0; + frame_drop_sync_1 <= 7'd0; for (si = 0; si < 6; si = si + 1) status_words[si] <= 32'd0; wr_state <= WR_IDLE; @@ -671,6 +734,7 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin cmd_opcode <= 8'd0; cmd_addr <= 8'd0; cmd_value <= 16'd0; + wr_done_toggle <= 1'b0; end else begin cmd_valid <= 1'b0; @@ -684,6 +748,10 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin stream_ctrl_sync_0 <= stream_control; stream_ctrl_sync_1 <= stream_ctrl_sync_0; + // AUDIT-C12: frame_drop_count CDC (clk → ft_clk for status read) + frame_drop_sync_0 <= frame_drop_count; + frame_drop_sync_1 <= frame_drop_sync_0; + // Status snapshot on request if (status_req_ft) begin // Word 0: {0xFF, mode[1:0], stream[5:0], threshold[15:0]} @@ -700,7 +768,11 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin status_chirps_mismatch, // [10] TX-G mismatch flag 8'd0, // [9:2] reserved status_range_mode}; // [1:0] - status_words[5] <= {7'd0, status_self_test_busy, + // AUDIT-C12: frame_drop_count exposed at status_words[5][31:25] + // (was 7'd0 reserved). Saturates at 127. Counts frame_complete + // events that arrived while previous frame was still in WR_FSM + // transit (silent frame drop indicator for host visibility). + status_words[5] <= {frame_drop_sync_1, status_self_test_busy, 8'd0, status_self_test_detail, 3'd0, status_self_test_flags}; end @@ -977,9 +1049,10 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin end WR_DONE: begin - ft_wr_n <= 1'b1; - ft_data_oe <= 1'b0; - wr_state <= WR_IDLE; + ft_wr_n <= 1'b1; + ft_data_oe <= 1'b0; + wr_done_toggle <= ~wr_done_toggle; // AUDIT-C12: signal frame transfer complete to clk domain + wr_state <= WR_IDLE; end default: wr_state <= WR_IDLE; @@ -1070,4 +1143,28 @@ always @(posedge clk) begin end `endif +// ============================================================================ +// AUDIT-S22: cfar_valid-vs-RMW-busy checker (simulation only) +// +// Detection RMW (idle→read→write-back) takes 3 cycles. cfar_ca emits one +// detect_valid pulse per 3 cycles (THR/MUL/CMP pipeline). They match by +// construction today — line 469 silently rejects cfar_valid arriving when +// detect_rmw_state != 0, which never fires at the current cadence. +// +// If cfar_ca is ever optimized to <3-cycle cadence (e.g., merging MUL+CMP, +// flagged as a possible target in cfar_ca.v), the silent rejection becomes +// a silent detection-drop. This assertion makes that violation loud, so the +// regression suite catches the coupling on the day someone speeds CFAR up +// without also pipelining the RMW. Synthesis-inert. +// ============================================================================ +`ifdef SIMULATION +always @(posedge clk) begin + if (reset_n && cfar_valid && frame_filling && !detect_clearing && + detect_rmw_state != 2'd0) begin + $display("[ASSERT FAIL] AUDIT-S22: cfar_valid arrived while RMW busy (state=%0d) — detection at range_bin=%0d doppler_bin=%0d dropped", + detect_rmw_state, range_bin_in, doppler_bin_in); + end +end +`endif + endmodule