dbglab's random test fails on seed 10, and the hunt in the debug loop has already localised it: the DMA's beat counter left_q is 8 bits wide, a run of 308 words loads 52, and the copy stops early. Two engineers send a patch the same afternoon.
Patch A sets CW = 9: "308 needs nine bits, and the random test never asks for more than 320." Patch B sets CW = $bits(cfg_len). Both make seed 10 pass. Both give the same clean 60-seed sweep. The reviewer has to pick one. Which, and how would you show it rather than argue it?
The usual test of a fix is the test that failed, then the regression. If both are green, the fix is merged. That works when the fix and the cause are the same thing. It fails when a fix covers the cases the tests make and no more, because the regression is exactly those cases. A fix sized to the failure will always look perfect to the tests that found the failure.
The other habit is to argue taste: "nine bits is enough", "deriving the width is cleaner". Arguments do not settle this. A run does, if you choose the input where the two patches disagree.
Name the cause as a decision, not a line. Here it is not "line 36 says 8". It is commit 03ebdc1 declaring the counter's width on its own, as a number, separate from the 10-bit length it must hold. Two widths that must agree were written in two places.
Place every candidate fix on the chain from cause to fault to symptom (symptom, fault, cause). A change after the fault, in the checker, the test or the driver, is a symptom fix. A change at the fault that covers only the values you saw is an instance fix: right place, wrong size.
Ask what range the cause covers. The register map says LEN is 0 to 1023. The failing test covered at most 320. A fix must hold for the first range, not the second.
Find the input where the fixes disagree, and run it on both before choosing. If no such input exists, they are the same fix; if one does, the choice is made by a result.
Prefer the fix that makes the bug impossible to write again. A width derived from its source cannot drift. Then remove anything that hides the next mistake, here the cast.
Read it top down. Every length the random test makes lies inside both lower bars, so it cannot tell patch A from patch B. Only the ticks at 512 and 1023 fall in the gap.
$ grep -n "localparam int CW" cw9/rtl/dma_engine.sv cwfix/rtl/dma_engine.sv
cw9/rtl/dma_engine.sv:36: localparam int CW = 9; // beat counter width
cwfix/rtl/dma_engine.sv:36: localparam int CW = $bits(cfg_len); // beat counter width: as wide as the length it counts
$ cw9/tb/run.sh random 10
dbglab: test=random seed=10
PASS test=random seed=10 cycles=21581 time=215806 ns
$ cwfix/tb/run.sh random 10
dbglab: test=random seed=10
PASS test=random seed=10 cycles=21581 time=215806 nsSame cycle count, to the cycle. Seed 10 cannot tell them apart, and neither can Verilator, which gives the same two lines.
$ for v in head cw9 cwfix; do (cd $v; for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done > ../sweep60-$v.txt); echo "$v: $(wc -l < sweep60-$v.txt) fail, $(grep -c xxxxxxxx sweep60-$v.txt) with X"; done
head: 19 fail, 9 with X
cw9: 9 fail, 9 with X
cwfix: 9 fail, 9 with X
$ diff sweep60-cw9.txt sweep60-cwfix.txt && echo same
sameTen wrong-value failures at HEAD, none with either patch. The nine left show an X, which is a different bug (B4), and they are line for line the same with both patches. If the review stops here, the patches are equal and the shorter one wins.
The disagreement is at lengths from 512 up. The register map, not the random test, says those are legal, so write the test from the register map. Name it for the rule it checks, not for the bug that prompted it:
// not-run: a task for dbglab's bench (tb/tests.svh); it needs the bench's helpers
// dma_len_limits: LEN is 10 bits, so every length from 0 to 1023 is legal (register map).
// Copy at the edges of each power of two that fits in RAM, then fill 1023 words.
task automatic t_dma_len_limits();
logic [31:0] st;
int len;
csr_wr(CSR_CTRL, 32'h0);
for (int k = 0; k < 4; k++) begin
case (k)
0: len = 255;
1: len = 256;
2: len = 511;
default: len = 512;
endcase
run_copy(0, 512, len);
for (int i = 0; i < len; i++) ram_check(512 + i);
end
csr_wr(CSR_FILL, 32'hA5A5_0000 ^ seed); // the longest legal run: a fill
csr_wr(CSR_CTRL, 32'h1 << CTRL_FILL);
dma_program(32'h0, RAM_BASE + 4 * 1, 1023);
dma_wait(st);
dma_close(st, 1'b0, 32'h0);
model_copy(0, 1, 1023, 1'b1);
for (int i = 0; i < 1024; i++) ram_check(i);
endtask$ head/tb/run.sh dma_len_limits 1
dbglab: test=dma_len_limits seed=1
FAIL test=dma_len_limits seed=1 cycle=4917: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #2)
$ cw9/tb/run.sh dma_len_limits 1
dbglab: test=dma_len_limits seed=1
FAIL test=dma_len_limits seed=1 cycle=15445: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #4)
$ cwfix/tb/run.sh dma_len_limits 1
dbglab: test=dma_len_limits seed=1
PASS test=dma_len_limits seed=1 cycles=24673 time=246726 nsRead the three results together:
HEAD fails on run #2, the 256-word copy, at cycle 4917: an 8-bit counter loads 0 for 256.
Patch A gets past runs #1 to #3 (255, 256 and 511 words) and fails on run #4, the 512-word copy, at cycle 15445. The same failure, on the same word, one power of two later.
Patch B passes all five runs, including the 1023-word fill. Seeds 2 and 3 and Verilator give the same three verdicts.
A probe on patch A's run #4 shows the mechanism, and why the bench needed a data check rather than a length check:
$ probes/run-probe.sh cw9 ../probes/dma_probe.sv dma_len_limits 1 | grep "run #4"
cycle 11329: DMA run #4 start LEN=512 left_q=0
cycle 15421: DMA run #4 done words written=512
FAIL test=dma_len_limits seed=1 cycle=15445: SCOREBOARD: RAM word 516 (address 00001810) read 228510de, expected 934c82b1 (last written by DMA run #4)left_q loads 0, the chunk logic misbehaves, and the run still writes 512 words, of the wrong data. A check that only counted words would have passed patch A.
Patch B keeps the CW'(cfg_len) cast, which is now a no-op. Remove it. Without the cast, a future narrowing is a lint warning instead of a silent wrap:
$ verilator --lint-only -Wall --top-module dma_engine ../cw9/rtl/dbglab_pkg.sv ../cw9/rtl/sync_fifo.sv dma_cw9.sv 2>&1 | grep WIDTH | grep dma_
%Warning-WIDTHTRUNC: dma_cw9.sv:89:19: Operator ASSIGNDLY expects 9 bits on the Assign RHS, but Assign RHS's VARREF 'cfg_len' generates 10 bits.
$ verilator --lint-only -Wall --top-module dma_engine ../cwfix/rtl/dbglab_pkg.sv ../cwfix/rtl/sync_fifo.sv dma_cwfix.sv 2>&1 | grep WIDTH | grep dma_; echo "exit $?"
exit 1The derived width prints no warning (grep finds nothing and exits 1); patch A without its cast would have been flagged at once. The cast was part of the cause: it told the tool the truncation was intended.
Patch A was a real change at the fault, in the right line, and it was still the wrong fix. It moved the wrap from 256 to 512, beyond every length the tests make, so the regression could never fail again. That is worse than no fix: the bug is now rarer, and its ticket is closed. Patch B removed the decision that made the bug possible. The run that separated them took a second, and it was found by asking one question: what range must this hold for?
Candidate | Acts on | Seed 10 | 60-seed sweep | dma_len_limits | Verdict |
|---|---|---|---|---|---|
Cap the random test at 255 words | the stimulus (symptom) | PASS | clean (9, all X) | FAIL at 256, as HEAD | workaround, not a fix |
CW = 9 | the fault, sized to the failure | PASS | clean (9, all X) | FAIL at 512 | instance fix |
CW = $bits(cfg_len) | the cause (two widths that must agree) | PASS | clean (9, all X) | PASS | the fix |
The first row is the most common symptom fix of all, and it was run too: one line in the random test, if (r_len > 255) r_len = 255;, makes seed 10 pass and the sweep clean while the design is untouched. The limits test fails on it exactly as it fails at HEAD, because the test programs LEN itself. The debug loop names this as a trap; here it is measured.
Probably not. Ask what the cause is: why did a producer write 17 entries? If the specification says the producer must respect full, the cause is a producer or flow-control bug, and a deeper FIFO is an instance fix that moves the failure to 33. Write the test where the two disagree: a burst longer than 32 with the consumer stopped. The cause fix (honouring full) passes it; the deeper FIFO fails it.
Only that the regression cannot tell them apart. It is a statement about the tests, not the patches. Diff the patches and ask which inputs reach the lines where they differ; then write a test for those inputs. If no legal input reaches the difference, they really are equivalent, and the clearer one wins.
dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh) in three scratch copies made with git archive: HEAD, CW = 9, and CW = $bits(cfg_len) at rtl/dma_engine.sv line 36. Icarus Verilog 13.0, 2026-10-18. tb/run.sh random 10: FAIL at cycle 4895 at HEAD, PASS (cycles=21581) with either fix, the same in Verilator 5.048.
Sweep: for s in $(seq 1 60); do tb/run.sh random $s | grep FAIL; done per copy: HEAD 19 failures (9 with an X), each fix 9 (all X, B4); diff of the two fix sweeps is empty. dma_len_limits (the task shown, added to tb/tests.svh with one dispatch line in tb/dbglab_tb.sv): HEAD FAIL at cycle 4917 (run #2, 256 words), CW = 9 FAIL at cycle 15445 (run #4, 512 words), the specification fix PASS (cycles=24673); seeds 1, 2, 3 and Verilator agree. A fourth copy with if (r_len > 255) r_len = 255; added to the random test: seed 10 PASS, 60 seeds 9 failures (all X), dma_len_limits FAIL at cycle 4917 as at HEAD. The probe line run #4 LEN=512 left_q=0 comes from a DMA probe compiled next to the bench.
Lint: with the CW'() cast removed, verilator --lint-only -Wall --top-module dma_engine reports %Warning-WIDTHTRUNC at line 89 for CW = 9 and nothing for the specification fix.
Limits: one bug, in simulation. The directed test covers five lengths, not all 1024; the next lesson closes that gap with a formal check. The review story is illustrative.