Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
    • Determinism and Seeds
    • Recording Everything to Replay
    • Shrinking a Failing Test
    • Reducing the Design
    • Making a Flaky Test Fail
    • When Simulation Cannot Reproduce It
    • Timeouts and Watchdogs in Benches
    • The Reproduction Checklist
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 1 · Reproduce
  1. Topics
  2. The Art of Hardware Debug
  3. Part 1 · Reproduce
  4. Recording Everything to Replay

Part 1 · Reproduce · Working (W)

Recording Everything to Replay

The replay record: what to save next to every failure (commit, local edits, command, simulator version, environment, the FAIL line) and a small script that replays it in a fresh checkout, shown on real dbglab failures.

On this page

    Why this matters

    Most "cannot reproduce" bugs were reproducible on the day they failed; what was lost is the commit, a local edit, a switch or the tool version. Writing a replay record at the moment of failure keeps the failure alive for whoever debugs it, weeks later, on another machine.

    After this lesson you can: write a replay record automatically for every failing run, replay it in a fresh checkout, and tell from the comparison whether the record is complete.

    Note

    Level: Working (W) · Debug-loop step: Reproduce

    Before this: Determinism and Seeds

    A failure with nothing behind it

    A colleague was trying an idea in dbglab's random test on her laptop, saw a failure, and pasted it into the tracker before leaving for two weeks:

    Terminal
    FAIL test=random seed=2 cycle=6964: SCOREBOARD: RAM word 186 (address 000012e8) read 58fab7f8, expected a0da7c74 (last written by DMA run #41)

    You check out the main branch and run seed 2. It passes. The line names a test, a seed and a cycle. What else would you need to see this failure again? (The story is illustrative; every run in this lesson is real.)

    What people usually do, and why it fails

    The usual record of a failure is the FAIL line, maybe with a branch name. Then the hunt for the rest begins: which commit was the branch on that day, was anything edited and not committed, which simulator, which switches? Every question is answered from memory, and memory is where reproductions go to die. Here the answer was a one-line edit on the laptop that made long DMA runs common. Nothing in the tracker says so, and seed 2 passes without it.

    The previous lesson listed the inputs that decide a run. This one makes sure they are written down, by a script, at the moment the run fails.

    The method: a replay record for every failure

    Field

    Example from dbglab

    Why it is needed

    commit

    49c5f04d1124... (the full hash)

    a branch name moves; a hash does not

    dirty and a patch

    dirty=yes and local.patch

    uncommitted edits are part of the program

    command

    tb/run.sh random 2 icarus

    seed, test and every plusarg, exactly as typed

    simulator

    Icarus Verilog version 13.0 (stable) (v13_0)

    a different version is a different scheduler and parser

    environment

    DUMP= XRAND=1

    switches that scripts read from the environment

    result

    the FAIL line

    the signature a replay must match

    host and time

    Darwin 25.6.0 arm64, a UTC time

    for the cases where the machine turns out to matter

    And the rules that make the record trustworthy:

    1. Write it automatically, at the moment of failure. A wrapper around the run command, not a habit. Nobody fills in a form at 2 a.m.

    2. Record facts, not names. A full commit hash, the version string the tool prints, the exact command line.

    3. Save the full log next to it. The FAIL line is the signature; the log is the context.

    4. Replay in a fresh checkout. Never in your working tree, which has moved on and may have its own edits.

    5. Compare the signature. A replay passes only if it prints the same FAIL line. Any other result means something is missing.

    The picture

    Flowchart: run the test; if it fails, write replay.txt with the commit, patch, command, simulator, environment and FAIL line. Later, replay.sh creates a fresh worktree at the commit, applies the patch, runs the same command in the same environment and compares the FAIL line: the same line means REPRODUCED, a different one means an input is missing or the replay is broken.
    Record when the run fails, replay later in a fresh checkout, and compare the FAIL line.

    Do it: record and replay on dbglab

    The recorder

    record-run.sh wraps dbglab's tb/run.sh. It passes the result through unchanged, and on a failure writes failures/<test>-<seed>-<sim>/replay.txt with a copy of the log. Keep it outside the repository, so checking out another commit does not change it.

    terminal
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    #!/bin/sh
    # record-run.sh: run one dbglab test; on failure, write everything needed to replay it.
    # usage: record-run.sh <test> <seed> [icarus|verilator] [plusargs...]   (run from the repository root)
    TEST=$1 SEED=$2 SIM=${3:-icarus}
    [ $# -gt 3 ] && shift 3 || shift $#
    out=$(tb/run.sh "$TEST" "$SEED" "$SIM" "$@"); status=$?
    echo "$out"
    [ $status -eq 1 ] || exit $status                  # record failures only
    dir=failures/$TEST-$SEED-$SIM
    mkdir -p "$dir"
    case $SIM in
      icarus)    ver=$(iverilog -V 2>&1 | head -1) ;;
      verilator) ver=$(verilator --version) ;;
    esac
    dirty=no
    if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
      dirty=yes; git diff HEAD > "$dir/local.patch"     # uncommitted edits are part of the recipe
    fi
    {
      echo "when=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
      echo "commit=$(git rev-parse HEAD)"
      echo "dirty=$dirty"
      echo "cmd=tb/run.sh $TEST $SEED $SIM${*:+ $*}"
      echo "simulator=$ver"
      echo "env=DUMP=${DUMP:-} XRAND=${XRAND:-}"
      echo "host=$(uname -srm)"
      echo "result=$(echo "$out" | grep '^FAIL')"
    } > "$dir/replay.txt"
    cp "build/$SIM-$TEST-$SEED.log" "$dir/"
    echo "recorded: $dir/replay.txt"
    exit 1

    It exits with the same status as tb/run.sh (0 pass, 1 fail, 125 no build), so it can sit inside a regression script or git bisect run unchanged.

    Two failures, two records

    First, seed 10 on a clean tree at HEAD. Then the colleague's case: the one-line edit that makes one DMA run in ten long instead of one in 400, and seed 2.

    Terminal
    $ ../replay/record-run.sh random 10 > /dev/null
    $ cat failures/random-10-icarus/replay.txt
    when=2026-10-17T16:13:52Z
    commit=49c5f04d1124aaeab1dc902884d49b7d1b4b8e75
    dirty=no
    cmd=tb/run.sh random 10 icarus
    simulator=Icarus Verilog version 13.0 (stable) (v13_0)
    env=DUMP= XRAND=
    host=Darwin 25.6.0 arm64
    result=FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)
    $ git diff
    ...
    -  else            r_len = (urange(0, 399) == 0) ? urange(257, 320) : urange(1, 24);  // 1 in 400 runs is long
    +  else            r_len = (urange(0, 9) == 0) ? urange(257, 320) : urange(1, 24);  // 1 in 400 runs is long
    ...
    $ ../replay/record-run.sh random 2
    dbglab: test=random seed=2
    FAIL test=random seed=2 cycle=6964: SCOREBOARD: RAM word 186 (address 000012e8) read 58fab7f8, expected a0da7c74 (last written by DMA run #41)
    recorded: failures/random-2-icarus/replay.txt
    $ grep -E 'dirty|cmd' failures/random-2-icarus/replay.txt; ls failures/random-2-icarus
    dirty=yes
    cmd=tb/run.sh random 2 icarus
    icarus-random-2.log
    local.patch
    replay.txt

    The second record says what the tracker entry did not: the tree was dirty, and local.patch holds the edit. (Its comment still says "1 in 400"; the patch records the code, not what the author meant.)

    The replayer

    terminal
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #!/bin/sh
    # replay.sh: rebuild the recorded state in a fresh worktree, rerun, compare the result line.
    # usage: replay.sh <dir>/replay.txt   (run from the repository root)
    rec=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")      # absolute: we cd into the worktree
    get() { sed -n "s/^$1=//p" "$rec"; }
    commit=$(get commit); cmd=$(get cmd); want=$(get result)
    sim=$(echo "$cmd" | cut -d' ' -f4)
    case $sim in
      icarus)    ver=$(iverilog -V 2>&1 | head -1) ;;
      verilator) ver=$(verilator --version) ;;
    esac
    [ "$ver" = "$(get simulator)" ] || echo "WARNING: simulator is '$ver', recorded '$(get simulator)'"
    wt=$(mktemp -d)/replay
    git worktree add -q --detach "$wt" "$commit" || exit 2
    if [ "$(get dirty)" = yes ] && ! git -C "$wt" apply "$(dirname "$rec")/local.patch"; then
      git worktree remove --force "$wt"; echo "replay: local.patch does not apply"; exit 2   # never run without it
    fi
    got=$(cd "$wt" && env $(get env) $cmd | grep -E '^(PASS|FAIL)')
    git worktree remove --force "$wt"
    echo "recorded: $want"
    echo "replayed: $got"
    if [ "$got" = "$want" ]; then echo "REPRODUCED"; exit 0; else echo "DIFFERENT"; exit 1; fi

    git worktree add gives a second checkout of the same repository at the recorded commit, without touching yours. The script applies the patch (and stops if it does not apply), runs the recorded command with the recorded environment, and compares the replay's PASS or FAIL line with the recorded one.

    Replay from somewhere else

    Now move on, as real life does: drop the local edit and check out an older commit, 12. Rerunning the commands by hand gives two passes. The records do better:

    Terminal
    $ git checkout -q tb/tests.svh; git checkout -q bc2bb34
    $ tb/run.sh random 10
    dbglab: test=random seed=10
    PASS test=random seed=10
    $ tb/run.sh random 2
    dbglab: test=random seed=2
    PASS test=random seed=2
    $ ../replay/replay.sh failures/random-10-icarus/replay.txt
    recorded: FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)
    replayed: FAIL test=random seed=10 cycle=4895: SCOREBOARD: RAM word 808 (address 00001ca0) read d5202ff9, expected b6c3311c (last written by DMA run #29)
    REPRODUCED
    $ ../replay/replay.sh failures/random-2-icarus/replay.txt
    recorded: FAIL test=random seed=2 cycle=6964: SCOREBOARD: RAM word 186 (address 000012e8) read 58fab7f8, expected a0da7c74 (last written by DMA run #41)
    replayed: FAIL test=random seed=2 cycle=6964: SCOREBOARD: RAM word 186 (address 000012e8) read 58fab7f8, expected a0da7c74 (last written by DMA run #41)
    REPRODUCED
    • The manual reruns passed because both inputs had changed: the commit (12 is before the bug) and, for seed 2, the missing edit.

    • The replays rebuilt both: commit 13 or later from the hash, and the edit from local.patch. Same line, same cycle.

    An input you cannot see: the environment

    Terminal
    $ git checkout -q main
    $ XRAND=1 ../replay/record-run.sh random 17 verilator
    dbglab: test=random seed=17
    FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read 5d88e2dd, expected 00000000 (last written by DMA run #4)
    recorded: failures/random-17-verilator/replay.txt
    $ grep env failures/random-17-verilator/replay.txt
    env=DUMP= XRAND=1
    $ tb/run.sh random 17 verilator
    dbglab: test=random seed=17
    PASS test=random seed=17 cycles=18442 time=184416 ns
    $ /usr/bin/time -p ../replay/replay.sh failures/random-17-verilator/replay.txt
    recorded: FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read 5d88e2dd, expected 00000000 (last written by DMA run #4)
    replayed: FAIL test=random seed=17 cycle=7949: SCOREBOARD: RAM word 816 (address 00001cc0) read 5d88e2dd, expected 00000000 (last written by DMA run #4)
    REPRODUCED
    real 8.93
    ...

    The command line is identical in the failing and the passing run; only an environment variable differs. XRAND=1 makes dbglab's script build Verilator with random initial values. The record kept it, so the replay failed the same way. The 8.9 seconds are mostly Verilator compiling the fresh checkout.

    What the evidence says

    All three records replayed exactly, from a working tree that by then passed all three seeds. Each record needed a different field: seed 10 needed the commit, seed 2 the patch, seed 17 the environment. A record that drops any one of them would have turned a real bug into "cannot reproduce". Once the replay prints REPRODUCED, you are at the next step of the loop: the failure is on demand, and the work moves to making it small.

    Traps

    DIFFERENT can mean the replay is broken

    The first version of replay.sh read the record and the patch by the relative paths it was given, after changing into the worktree, and it printed only a FAIL line. The patch failed to apply, so it ran seed 2 without the edit; that run passed, and the comparison reported a difference:

    Terminal
    $ ../replay/replay.sh failures/random-2-icarus/replay.txt
    error: can't open patch 'failures/random-2-icarus/local.patch': No such file or directory
    sed: failures/random-2-icarus/replay.txt: No such file or directory
    recorded: FAIL test=random seed=2 cycle=6964: SCOREBOARD: RAM word 186 (address 000012e8) read 58fab7f8, expected a0da7c74 (last written by DMA run #41)
    replayed:
    DIFFERENT

    The error lines are the giveaway, not the empty "replayed" line: that version printed the same empty line for any passing replay. A replay tool that cannot read its own record must stop, not compare. The version above makes the paths absolute, exits when the patch does not apply, and prints the replay's PASS or FAIL line; run against this record without its patch, it now prints replay: local.patch does not apply and exits with status 2. Test the tool on a fresh record, straight after writing it, while you know it must reproduce. A replay tool that has never printed REPRODUCED has not been tested.

    Common pitfall

    Recording names instead of facts. "main", "latest Verilator" and "the usual switches" all change meaning over time. Record the full hash, the tool's own version string, and the command exactly as run. If the simulator comes from a module system or a container, record that version too.

    Why this matters

    Senior lens. Put the recorder in the regression, not in people's habits. Every failing run in the nightly should leave a record and its log next to the result, and the bug tracker should link to that record. Triage then starts from replay.sh, not from a conversation.

    Keep old tool versions installable for as long as you keep records that name them. A record that names a simulator nobody can run any more is only half a record; replaying across a tool upgrade is a test in its own right.

    Interview

    Asked: "What information do you need to reproduce a failing simulation from last week's regression?"

    Weak answer: The test name and the seed. With those I can rerun it.

    Strong answer: The test and seed are only part of it. I need the exact commit hash and any uncommitted changes, the full command line with every plusarg, the simulator and its version, compile options and environment variables the scripts read, and the original FAIL line so I can tell whether my replay is the same failure. I would have the regression write all of that automatically next to each failure, and replay it in a clean checkout of that commit.

    Follow-ups: (1) The replay passes. What do you check? (2) How would you handle a failure that only happens on the compute farm?

    Check yourself Your replay of a regression failure prints DIFFERENT: the recorded line is a SCOREBOARD error at cycle 4895, the replay passes. Where do you look first?

    At the record and the replay, before the design. Is the replay command identical (it might be missing a plusarg)? Does the simulator version match (the script would print a warning)? Did the record catch the environment and any local edits? Did every step of the replay succeed (a patch that failed to apply leaves a passing run)? If all of those match and the replay still passes, the run is not deterministic, and that is a bug in the bench to fix first.

    Check yourself A teammate says recording the full log is wasteful, because the FAIL line is all anyone reads. What does the log give you that the line does not?

    Context for the first look, and a second check on the replay. The log shows what happened before the failure (which DMA run, how many operations, any warnings), so you can start observing without a rerun. And a replay whose FAIL line matches but whose log differs earlier is a warning that the run is not quite the same. For dbglab the log is a few lines; on a large design it may be the only record of a run that took hours.

    Remember

    • Record at the moment of failure, by script: commit hash, local edits as a patch, exact command, simulator version, environment, and the FAIL line.

    • Replay in a fresh checkout of the recorded commit, never in your working tree, and compare the FAIL line, not just pass or fail.

    • A replay that gives a different result means the record is missing an input, or the replay is broken. Test it once on a fresh record.

    Rule of thumb: if a failure was not recorded when it happened, assume it cannot be replayed.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), Icarus Verilog 13.0, Verilator 5.048, git 2.54.0, 2026-10-17. record-run.sh and replay.sh as shown, kept outside the repository and run from its root. ../replay/record-run.sh random 10 on a clean tree; ../replay/record-run.sh random 2 with the one-line local edit shown (FAIL at cycle 6964, dirty=yes, local.patch saved).

    Replays after git checkout tb/tests.svh and git checkout bc2bb34 (commit 12): tb/run.sh random 10 and tb/run.sh random 2 pass there; replay.sh on both records prints REPRODUCED. XRAND=1 ../replay/record-run.sh random 17 verilator, then at HEAD tb/run.sh random 17 verilator passes and replay.sh on the record prints REPRODUCED in 8.9 s (most of it Verilator's build).

    The broken first version of replay.sh (relative paths to the record and patch, used after changing directory; FAIL lines only) printed the errors and DIFFERENT shown in Traps: the patch did not apply and seed 2 passed without it (with a PASS-or-FAIL grep the same script prints replayed: PASS test=random seed=2 cycles=20526 time=205256 ns). The version shown, with local.patch removed, prints replay: local.patch does not apply and exits 2. All three replays rerun with it on 2026-10-18: REPRODUCED.

    Limits: one machine, one installed version of each tool, so the simulator-version warning was not triggered. The record covers what dbglab's scripts read; a project with more inputs (defines, tool modules, licence-dependent options) must record those too.

    Keep going

    • Code lab: Seed plusarg

    • Interview: run a mock interview on this topic

    Related topics

    • Part 1 · Reproduce

    • Next: shrinking a failing test

    • Determinism and seeds

    • Bisection in history: the same exit codes, used by git bisect run

    • UVM: regression triage

    Previous

    8 / 107

    Next: Shrinking a Failing Test

    Up next

    Up nextShrinking a Failing Test

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Reducing the Design
    • Making a Flaky Test Fail
    • When Simulation Cannot Reproduce It