Describe a waveform layout once, in Python, and generate the save file every viewer wants.
A testbench worth debugging twice deserves a signal list worth keeping. Every viewer stores one, but each in its own
format — GTKWave's TCL and its save file,
Modelsim's do script,
Surfer's command file — none of which is pleasant to write by hand, all
of which drift the moment a port is renamed. Wavedisp keeps the description in one Python file next to the RTL, under
version control, and emits the rest:
wavedisp -t gtkwave -o counter_tb.gtkwave.tcl counter_tb.wave.py
wavedisp -t modelsim -o counter_tb.modelsim.tcl counter_tb.wave.py
wavedisp -t rivierapro -o counter_tb.rivierapro.tcl counter_tb.wave.py
wavedisp -t surfer -o counter_tb.sucl counter_tb.wave.py
wavedisp -t gtkwave-savefile -o counter_tb.gtkw -D counter_tb.fst counter_tb.wave.pyBecause a description is a Python program, it can take parameters, loop over generate blocks, and be included by another description — the same way the RTL it follows is written.
Given a dump of the run, wavedisp also reports the declared signals it does not hold, and lists what it does — see Checking a description.
pip install wavedispRequires Python 3.10 or later. The only runtime dependency is lz4, which decodes the hierarchy of the FST files recent writers produce. For a checkout:
git clone https://github.com/cclienti/wavedisp.git
cd wavedisp
pip install -e .A description is a Python module with a function — generator by default — returning a tree of nodes:
# counter_tb.wave.py
from wavedisp.ast import Disp, Divider, Hierarchy
def generator():
testbench = Hierarchy('counter_tb')
testbench.add(Disp(['clk', 'rst_n']))
dut = testbench.add(Hierarchy('dut'))
dut.add(Divider('control'))
dut.add(Disp(['enable', 'load']))
dut.add(Disp('count', radix='unsigned'))
return testbenchGenerate a save file and open it:
wavedisp -t gtkwave -o counter_tb.gtkwave.tcl counter_tb.wave.py
gtkwave -S counter_tb.gtkwave.tcl counter_tb.vcdSignal names are written relative to the enclosing Hierarchy, so count above resolves to counter_tb.dut.count.
Disp adds one row per signal. It takes a name or a list of them:
dut.add(Disp('count'))
dut.add(Disp(['enable', 'load', 'done']))A name may carry a path of its own, which saves declaring a Hierarchy for a single signal:
dut.add(Disp('fifo_inst/write_ptr'))Hierarchy sets the instance path its children are resolved against. Nesting them concatenates:
testbench = Hierarchy('counter_tb') # counter_tb
dut = testbench.add(Hierarchy('dut')) # counter_tb.dut
fifo = dut.add(Hierarchy('fifo_inst')) # counter_tb.dut.fifo_inst
fifo.add(Disp('full')) # counter_tb.dut.fifo_inst.fulladd returns the node it was given, which is what makes that read top-down.
Generate blocks are addressed by their elaborated path, exactly as the simulator names them:
for index in range(4):
lane = dut.add(Hierarchy(f'gen_lane[{index}].lane_inst'))
lane.add(Disp('valid'))Group collects its contents into one foldable row:
group = dut.add(Group('write port'))
group.add(Disp(['wr_en', 'wr_addr', 'wr_data']))Groups nest, and they are what keeps a large default view usable — see Keeping the default view small.
Divider inserts a labelled separator:
dut.add(Divider('handshake'))Block groups nodes without producing a row of its own. It exists to apply a property to several nodes at once, and to
give a description a root when it has no natural one:
block = Block(radix='hexadecimal') # applies to everything inside
block.add(Disp(['addr', 'data']))Three properties are accepted by every node, as keyword arguments:
| Property | Values | Meaning |
|---|---|---|
radix |
binary, hexadecimal, signed, unsigned, octal, string, symbolic |
how the value is rendered |
color |
any X11 colour name — red, SteelBlue, … |
trace colour |
height |
a pixel count, e.g. 32 |
row height |
dut.add(Disp('count', radix='unsigned'))
dut.add(Disp('state', radix='symbolic', color='SteelBlue'))
dut.add(Disp('sample', radix='signed', height=32))A property set on a node applies to every descendant that does not set its own, so the common case is written once:
regs = dut.add(Group('registers', radix='hexadecimal'))
regs.add(Disp(['r0', 'r1', 'r2'])) # hexadecimal, inherited
regs.add(Disp('flags', radix='binary')) # binary, its own choice winsNot every viewer honours every property — see Targets.
include pulls in another description and attaches it under the current node. The usual arrangement is one file per
module, describing that module's own signals with no idea where it will be instantiated, plus one per testbench that
places them:
# fifo.wave.py — the module's own signals, relative to itself
from wavedisp.ast import Block, Disp, Divider
def generator():
block = Block()
block.add(Disp(['wr_en', 'rd_en', 'full', 'empty']))
block.add(Divider('internals'))
block.add(Disp(['write_ptr', 'read_ptr'], radix='unsigned'))
return block# fifo_tb.wave.py — where those signals live in this testbench
from wavedisp.ast import Disp, Hierarchy
def generator():
testbench = Hierarchy('fifo_tb')
testbench.add(Disp(['clk', 'rst_n']))
dut = testbench.add(Hierarchy('dut'))
dut.include('fifo.wave.py')
return testbenchA relative include resolves against the directory of the file containing it, not the working directory, so a description can be included from anywhere:
lane.include('../../fifo/project/fifo.wave.py')include returns the included tree, so it can be extended in place:
tree = dut.include('fifo.wave.py')
tree.add(Disp('debug_state'))A generator is an ordinary function, so it can take arguments, and callers pass them through include:
# parmem.wave.py
from wavedisp.ast import Block, Disp, Group, Hierarchy
def generator(nb_banks=4, internals=False):
block = Block()
block.add(Disp(['en', 'addr', 'dout']))
if internals:
for bank in range(nb_banks):
group = block.add(Group(f'bank {bank}'))
group.add(Hierarchy(f'gen_bank[{bank}].bank_inst')).add(Disp('doa'))
return blockdut.include('parmem.wave.py', nb_banks=8, internals=True)The top-level generator takes its arguments from the command line, as JSON:
wavedisp -t gtkwave -a '{"nb_banks": 8, "internals": true}' -o out.tcl parmem_tb.wave.pyUse -g when the function is not called generator:
wavedisp -g post_synth_generator -o out.tcl parmem_tb.wave.pywavedisp [-h] [-o OUTPUT] [-t TARGET] [-g GENERATOR] [-a KWARGS] [-T TARGET_KWARGS] [-D DUMP] [-v] [-d] [input]
| Option | Meaning |
|---|---|
input |
the description file — omitted to list what -D holds |
-o, --output |
output filename |
-t, --target |
gtkwave (default), modelsim, rivierapro, surfer, dot |
-g, --generator |
name of the generator function (default generator) |
-a, --kwargs |
JSON object passed to the generator function |
-T, --target-kwargs |
JSON object passed to the target |
-D, --dump |
the simulation dump — see Checking a description |
-v, --verbose |
log every file included and the generator used for it |
-d, --debug |
more of the same |
-D does one of two things, depending on whether there is anything to render. With a description it is what the
declared signals are checked against; on its own it is what gets listed.
-a and -T are easy to confuse, and they reach different places: -a parameterises what is described, -T how
it is rendered. An option the selected target does not take is reported and exits non-zero rather than being silently
ignored.
Anything that goes wrong — an unknown radix, a colour that is not an X11 name, a missing include — is logged with the file and line of the node that caused it, and makes wavedisp exit non-zero, so a Makefile stops rather than leaving a half-correct file behind.
| Viewer | -t |
Output | Load it with |
|---|---|---|---|
| GTKWave | gtkwave |
TCL script | gtkwave -S layout.gtkwave.tcl dump.vcd |
| GTKWave | gtkwave-savefile |
.gtkw save file |
gtkwave dump.fst layout.gtkw — needs -D, see below |
| Modelsim / Questa | modelsim |
TCL script | vsim -do 'do layout.modelsim.tcl; run -all' tb |
| Aldec Riviera-PRO | rivierapro |
TCL script | vsim -do 'do layout.rivierapro.tcl; run -all' tb |
| Surfer | surfer |
.sucl command file |
surfer dump.vcd --command-file layout.sucl |
| Graphviz | dot |
.dot graph |
xdot layout.dot — renders the tree, for debugging a description |
What each one honours:
radix |
color |
height |
groups | |
|---|---|---|---|---|
| GTKWave | all seven | nearest of 7 | ignored | yes |
| GTKWave save file | all seven | nearest of 7 | ignored | yes |
| Modelsim | all seven | exact RGB | pixels | yes |
| Riviera-PRO | see note | exact RGB | pixels | yes |
| Surfer | all seven | nearest of 8 | converted | yes |
Two targets for one viewer, and they are not interchangeable.
gtkwave writes a TCL script: GTKWave runs it, resolves the names it is given, and can be told things a file cannot
express. gtkwave-savefile writes the save file GTKWave writes itself — the one it opens beside a dump, with no -S,
and the one it rewrites when you save your layout from the GUI:
wavedisp -t gtkwave-savefile -o tb.gtkw -D tb.fst tb.wave.py
gtkwave tb.fst tb.gtkwThe save file names each row exactly as the dump declares it, bit range included — tb.dut.doa[31:0] where the
description says doa — which is why -D is required rather than optional there. A signal the dump does not hold is
reported and left out, a row GTKWave cannot bind being worse than no row.
The dump has to be a VCD or an FST. LXT, LXT2 and VZT keep their bit ranges in a geometry table this package does not read, so every bus would be named without one; that is refused outright rather than written.
Colours are reduced to the seven GTKWave supports, by nearest RGB, in both targets. height has no equivalent and is
dropped — GTKWave stores no per-trace height at all, and the save file target says so on each row that asks for one.
Both take an exact RGB colour and a pixel height.
The Riviera-PRO radix mapping looks wrong and predates the current maintainers of this file: a radix is emitted as
add wave -radix -hex, combining the long option with the shorthand value, and symbolic maps to nothing at all,
leaving a -radix with no value after it. The behaviour is pinned by the target's reference test, so it has been this
way for a long time; it has not been re-checked against a real Riviera-PRO installation, and cannot be — see
Contributing. If you use that target, check what it emits before trusting the radix property.
Surfer has no scripting language: a command file is a flat list of the commands its prompt accepts, and each one acts on the row Surfer has focused. The target therefore tracks every row index itself and emits the focus commands to match, because a command file cannot read anything back. Three consequences are worth knowing.
A signal missing from the dump shifts everything after it. It adds no row, while the file counted one, so later
commands land one row off. Surfer logs the failed variable_add, and its dump_tree command prints the tree it
actually built. Nothing in the command language can detect this from the inside, so a description that has drifted from
the RTL fails worse here than elsewhere.
Three characters cannot appear anywhere. Each line is trimmed, truncated at the first #, then split on ;, all
before any command is parsed, and none of it can be quoted or escaped. In a name they are replaced by _ and
reported. In a signal path the signal is dropped and reported instead — a substituted path names something the dump
does not contain, which would shift every later row.
Colours are theme names, not values. They are matched against the eight of Surfer's default theme, by name first
and nearest RGB otherwise. The ibm, petroff-* and *-high-contrast themes define different names, and a name a
theme does not define leaves the row at its default colour.
height keeps its meaning across targets. Modelsim and Riviera-PRO take a pixel count; Surfer has no pixel form, only
a factor on its configured line height, and draws a row waveforms_line_height * factor tall. The target divides by
that line height, so height=32 is a 32-pixel row everywhere. If your Surfer configuration changes
layout.waveforms_line_height from its default of 16, say so:
wavedisp -t surfer -T '{"line_height": 20}' -o layout.sucl tb.wave.pyGTKWave slows to a crawl once a few hundred rows are displayed, which is easier to reach than it sounds: a testbench with several instances, each pulling in its sub-hierarchies, runs to several hundred signals without anyone intending it. Show ports by default and put the detail behind a keyword the caller opts into:
def generator(internals=False):
block = Block()
block.add(Disp(['en', 'wen', 'addr', 'dout'])) # always
if internals:
block.add(Divider('internals'))
block.add(Disp(['state', 'next_state']))
return blockdut.include('parmem.wave.py', internals=True) # this instance only
other.include('parmem.wave.py') # ports onlyAn instance that needs two signals is better served by naming them than by including a whole module description:
other.add(Disp(['dout', 'freeze']))%.gtkwave.tcl: %.wave.py
wavedisp -t gtkwave -o $@ $<
%.sucl: %.wave.py
wavedisp -t surfer -o $@ $<
trace: $(TB).vcd $(TB).gtkwave.tcl
gtkwave -S $(TB).gtkwave.tcl $(TB).vcdWhere the dump is a prerequisite anyway, name it: the rule then fails on a signal that moved, instead of producing a view with a row missing from it.
.DELETE_ON_ERROR:
%.gtkw: %.wave.py %.fst
wavedisp -t gtkwave-savefile -o $@ -D $*.fst $<
trace: $(TB).fst $(TB).gtkw
gtkwave $(TB).fst $(TB).gtkw.DELETE_ON_ERROR: is what makes that stick, and it is worth the line. A failed check does not stop the file from
being written — it may well be the right file for the next run — so without it make would find a target newer than its
prerequisites on the second invocation, report nothing, and hand over the view whose row is missing. The rule would
fail once and pass for ever after.
Generated save files are build artefacts: keep the .wave.py in version control and leave the rest out.
Nothing generates a description from the RTL, so a renamed port leaves a signal silently absent — an empty row in the viewer, and nothing else to say so. Pass a dump of the run and every declared signal is looked up in it:
wavedisp -t gtkwave -o tb.tcl -D tb.fst tb.wave.pytb.wave.py:23: signal "tb.dut.addrra" not found in "tb.fst"
The dump may be a VCD, FST, LXT, LXT2 or VZT file, gzipped or not, and the format is recognised from the content rather than from the suffix. Only the declarations are read, never the value changes, so the check costs the same on a dump of a few kilobytes and on one of several gigabytes. A failed check does not veto the generation — the file may well be right for the next run — but it does make wavedisp exit non-zero.
The wavedisp.dump package does that lookup and nothing else, should a script need it:
from wavedisp.dump import read_signals
signals = read_signals('tb.fst')
'tb.dut.clk' in signalsThe other direction, for when the question is what the design is even called — a generate block whose elaborated name is anyone's guess, or the instance path a signal ended up under:
wavedisp -D tb.fsttb.dut.clk
tb.dut.gen_lane[2].lane_inst.valid
tb.dut.count[7:0]
One path per line, in declaration order, spelled the way a Disp wants them — a line can be pasted into a description
as it stands, bit range included. Sorting, filtering and counting are what the shell is for:
wavedisp -D tb.fst | grep fifo_inst
wavedisp -D tb.fst | wc -lThe mode is chosen by what is there to render: a -D with no description lists, a -D next to one checks. An
-o without a description is refused rather than filled with the list, a forgotten description being exactly
what that looks like.
The dot target renders the tree wavedisp built, which is the quickest way to see what a parameterised description
actually produced:
wavedisp -t dot -o layout.dot tb.wave.py && xdot layout.dotRunning with -v lists every file included and the generator used for it.
git clone https://github.com/cclienti/wavedisp.git
cd wavedisp
uv run --group dev pytest
uv run --group dev ruff check wavedisp tests
uv run --group dev ruff format --check wavedisp testsSome tests run other programs and skip without them, which the CI installs so that they cannot: tclsh replays the
generated GTKWave scripts, and the fst2vcd, lxt2vcd and vzt2vcd helpers of GTKWave read the binary dump fixtures
back, so that the readers are confronted with the reference implementation of each format rather than with themselves.
Regenerating those fixtures — tests/data/regenerate.sh — additionally wants Icarus Verilog and Verilator, and is not
part of running the tests.
A target is a Target subclass in wavedisp/targets/, that is:
- declares
name, the name-ttakes for it; - does its work in
__init__— header,visit(tree), footer — implementingprocess_group,process_dividerandprocess_disp, and leaves the file ingenstr; - is added to
TARGET_CLASSESinwavedisp/cli.py, which is where the registry, the-tchoices and the help text all come from.
Constructor keyword arguments become -T options automatically: Target.options() reads them off the signature, and
a target whose options cannot be read that way overrides it. An argument the command line fills in itself rather than
the user — the dump gtkwave-savefile names its rows from — is listed in provided instead, which keeps it out of
-T.
Pull requests are welcome, and there is one area where they are needed rather than merely welcome: the Modelsim and
Riviera-PRO targets can no longer be tested by the maintainer, who no longer has access to either tool. They are
still generated and still covered by their reference tests, but nobody here can load their output into the simulator
it was written for. If you use one of them, a report that it works — or a patch when it does not — is worth more than
it looks. The radix mapping in the Riviera-PRO target described above is exactly the
kind of thing that has gone unnoticed as a result.
The GTKWave and Surfer targets are checked against the real viewers.
Wavedisp is distributed under the GPLv3, whose complete text can be found here.