Simulation
Procedural testbenches
Testbenches live beside hardware modules and run with the native simulator.
The test command runs every test in a source file; pass a test name to run
only that test. A test may bind a module with for, which brings its ports into
scope, or instantiate a DUT locally when it needs an unbound test.
In the playground, choose Tests from the Output menu and press Compile
to run the same testbenches in the browser.
module XorGate(a: bool, b: bool) -> (out: bool) {
out = a xor b
}
const XorTruthTable = [
3'b000, // a, b, out
3'b011,
3'b101,
3'b110,
]
module Counter[Width: Nat](
clk: clock,
rst: bool,
enable: bool,
) -> (value: uint[Width]) {
let state = Reg[uint[Width]](clk, rst, en: enable)
state.d = state.q + 1
value = state.q
}
test "complete XOR truth table" for XorGate {
for i in 0..<4 {
drive!(a, XorTruthTable[i][2])
drive!(b, XorTruthTable[i][1])
expect!(out, XorTruthTable[i][0])
}
}
test "counter reset, increment, and hold" for Counter[8] {
drive!(rst, true)
drive!(enable, false)
step!(1)
expect!(value, 0)
drive!(rst, false)
drive!(enable, true)
for expected in 1..<6 {
step!(1)
expect!(value, expected)
}
drive!(enable, false)
step!(2)
expect!(value, 5)
}
Each row of the XOR table is a three-bit a, b, out value. The test indexes
the constant table to drive the inputs and check its expected output.
| Builtin | Form | Effect |
|---|---|---|
drive! | drive!(input, value) | Drives an input port and settles combinational logic. |
expect! | expect!(signal, value) | Checks a settled port value. The test fails when it differs. |
peek! | peek!(signal) | Reads a port value for use in a test expression or local binding. |
settle! | settle!() | Settles combinational logic without advancing time. |
step! | step!(cycles) | Advances the bound DUT's sole clock by complete cycles. |
step! | step!(clock, cycles) | Advances the named clock by complete cycles. Required when a DUT has multiple clocks, and used by unbound tests. |
Bound tests refer to ports directly. Unbound tests qualify ports with their DUT
binding, such as dut.out. Generic DUTs are supported in a bound declaration
such as for Counter[8].
Local DUT declarations select the module to simulate; their connection list
must be empty. Use drive! for test stimulus after let dut = Module().
Integer expressions and values returned by peek! may be stored in lexical
let or const bindings and composed in later test expressions.
Tests are host-side programs and do not add hardware to the emitted FIRRTL.
The DUT instances are still monomorphized, so a parameterized DUT used only by
a test is available to the simulator. Module-level assert!, printf!, and
stop! retain their clocked FIRRTL semantics and can be used alongside a
procedural test.
Yodl's simulator executes the normalized, typed FIRRTL produced by the compiler. It is intended for deterministic testbenches and for interactive playground tools; it does not model gate delays or analogue timing.
The low-level API accepts typed FIRRTL and prepares it into an executable
design. Simulator::from_circuit remains a compatibility wrapper; hosts that
run multiple instances should prepare once and reuse the compiled design:
let sim = @simulator.Simulator::from_circuit(circuit, "Top")
sim.poke("rst", @simulator.SimValue::from_int(1, 1))
sim.step_clock("clk")
let value = sim.peek("counter")
For procedural tests, wrap it in a testbench. step performs a rising and
falling edge, while run repeats that operation and tracks the cycle count.
let tb = sim.testbench()
tb.poke("rst", @simulator.SimValue::from_int(1, 1))
tb.run("clk", 2)
tb.expect("done", @simulator.SimValue::from_int(1, 1))
Values are arbitrary-width packed integers. A value can be marked unknown with
SimValue::new(..., known=false); unreset registers and uninitialised memory
locations start unknown. This prevents a test from accidentally relying on an
implicit zero initialisation. Unknownness is tracked per bit and unknown
backing bits are canonicalized. is_known() is true only when every valid bit
is known; extraction, concatenation, shifts, bitwise operations, masked writes,
and muxes preserve bits that can be proven known. Arithmetic uses a documented
conservative whole-result rule.
poke changes an input and settle reaches a stable combinational result,
including asynchronous reset effects, without advancing a cycle. A rising
edge samples register inputs, memory controls, and clocked commands from one
settled state. Register and memory updates become visible together, followed
by another settle; a stop! completes its triggering edge. The simulator
settles combinational connections between clock events, commits
registers and synchronous memories together on a rising edge, and executes
clocked printf!, assert!, and stop! commands. A combinational loop or an
external module without a registered simulation model is reported as an error.
Clocked commands require a real input clock (direct aliases are allowed;
derived clocks are rejected). Constant assertions are folded at compile time.
Unsupported memory latencies and unsupported indexing fail during preparation.
Outcomes expose persistent assertion failure separately from halting and
preserve nonzero stop codes. drain_events() returns typed printf/assertion
events incrementally; native testbenches may opt into full history.
For long-running tests, the evaluator compiles typed FIRRTL into a small SimIR worklist. Only statements affected by a changed input, register, memory, or child output are revisited, and machine-word primitive operations avoid allocating arbitrary-precision temporaries for the common narrow-control paths. The resulting cycle/settle API is deterministic while remaining suitable for a future bytecode backend.
Transition tracing is opt-in, so normal simulation remains fast:
sim.enable_waveform(signals=["valid", "ready"])
sim.step_clock("clk")
for sample in sim.waveform() {
println("{sample.cycle()}: {sample.signal()} = {sample.value()}")
}
External FIRRTL modules can be supplied by a host model. The model receives its input values and returns output values in declared port order:
let models : Map[String, @simulator.PrimitiveModel] = Map([])
models["TimerIP"] = {
evaluate: (_) => [@simulator.SimValue::from_int(1, 1)],
}
let sim = @simulator.Simulator::from_circuit_with_models(circuit, "Top", models)
Graphical and text-producing designs can expose semantic output protocols to a
simulation host. Framebuffer stores RGB pixels for a browser canvas, and
TextSink collects byte-oriented output. A hardware wrapper can connect those
same logical signals to a physical display or serial link when targeting an
FPGA, but the simulation top does not need those timing signals.
The playground uses this separation for the Game of Life example: GameOfLifeSim
loads its 30×40 vector register in one cycle, advances one generation per
clock, and returns the aggregate state as a framebuffer.
A simulation display can be a logical array or a pixel stream. The compiler
binds displays to typed outputs; signal names do not need a special prefix.
A sole two-dimensional unsigned output is detected automatically. Use
display: { buffer: "name" } to choose between multiple arrays.
@simulation({ display: { buffer: "pixel" }, reset: "rst" })
module VisualSim(clk: clock, rst: bool) -> (pixel: [30][40]bool) {
for row in 0..<30 {
for col in 0..<40 {
pixel[row][col] = rst
}
}
}
The attribute selects the module it is attached to. A sole clock is inferred;
multiple clocks require an explicit "clock": "name". reset: "rst" asserts
that one-bit input for one cycle, then deasserts it. Use
reset: { signal: "rst", cycles: 2 } when more reset cycles are needed.
Reset signals must actually initialize the design's registers: declaring a
reset sequence alone does not initialize unconnected registers.
For [height][width]bool, dimensions and monochrome mode come from the type.
The simulation lowering keeps each boolean row as a packed value, combines
its bit writes, and uses bit slices for reads. Registers and connections
execute on those packed rows. The host reads them directly as Uint32Array
words with ceil(width / 32) words per row; trailing bits are padding.
The worker transfers a matching validity mask instead of exporting one decimal
string per pixel, so an unknown bit affects only its corresponding pixel rather
than the entire packed row.
Other unsigned element types default to RGB integers (0xRRGGBB). Use
display: { buffer: "image", mode: "gray" } for grayscale or set on_color
and off_color for monochrome colors. Mode never depends on the current
pixel values, so an initially black RGB image remains an RGB image.
The panel shows unknown values in magenta and reports an initialization hint.
Zoom is a canvas setting and does not change the circuit or framebuffer shape.
Noise.yodl exposes an 80×60 logical RGB framebuffer with
@simulation({ display: { buffer: "pixel" }, reset: "rst" }). Each pixel consumes a
successive LFSR state in row-major order. One clock edge advances the seed by
one complete frame, so adjacent frames continue the sequence without
reusing overlapping samples. The example has no scan counters or VGA signals;
canvas zoom controls its displayed size.
Designs that already produce timed pixels can instead use a pixel stream:
@simulation({
display: { stream: "video", width: 640, height: 480 },
reset: "rst",
})
The video output is a named tuple with unsigned x and y coordinates,
a boolean valid, and unsigned r, g, and b channels of one to eight
bits. The host samples a valid pixel before each rising edge. A return to
valid coordinate (0, 0) marks the next frame; blanking cycles still execute
but do not paint pixels. Dimensions describe the active image, so blanking
and counter widths need not match them. Pixels not yet captured are shown in
magenta. The compiler resolves x, y, valid, and color channels through
the typed lowering table, so a source output such as video_x cannot collide
with the actual stream field binding.
The panel uses one persistent worker session for Run, Pause, Resume, input edits, and manual steps. Changing an input settles the circuit and refreshes the canvas. Reset creates a fresh machine from the already compiled design. Stop releases the session; the next Run compiles a new one. Source edits also stop the session. Scalar signals and messages appear below the display.
Source tabs load imported files and their dependencies automatically when you open or edit a design, independently of automatic compilation. Imports open read-only, with their full path in the editor header. The Main file remains the target for compilation, simulation, drafts, and sharing while you browse. Save downloads the file you are viewing.
Step cycle advances one clock. For arrays, Step frame advances
cycles_per_frame (default one). Declare it only when a complete frame needs
multiple cycles; it must be a positive integer. For streams, Step frame runs
until the next frame boundary and reports the actual cycles advanced. Batch
capture completes the first stream frame before returning its first image.
Execution is divided into bounded chunks so Pause can interrupt a long frame
step; reaching the bounded budget without a frame boundary produces a specific
diagnostic.
clock_hz sets the target simulation speed. Without it, clocked array and
scalar designs default to 30 cycles per second; pixel streams run as fast as
possible. The UI's Refresh FPS setting independently limits canvas updates
(default 30). It never sets the clock speed or the number of cycles advanced
by Step frame. The UI reports achieved cycles per second and simulated time
when a target frequency is configured. Resolved defaults appear in Advanced
settings. Timing changes apply on Resume or the next manual step; top/clock
changes require Stop.
Capture length and canvas refresh are run options, not circuit annotations.
The batch API accepts captureFrames (default one) and optional
cyclesPerFrame; realtime playback accepts refreshFps. A clockless design
settles and displays its image without any timing configuration. For example,
ImageSim needs only @simulation({ display: { buffer: "pixel" } }).
Every framebuffer annotation uses the same display object. Logical arrays
need only buffer; colors and grayscale mode are optional fields in that
object. Designs that expose explicitly packed words use that same object with
width, height, and packing, for example:
display: {
buffer: "pixel",
width: 400,
height: 300,
mode: "binary",
packing: "bits32",
on_color: 8116210,
off_color: 1056800,
}
bits and bits32 store 8 and 32 horizontal monochrome pixels per word;
rgb332x4 stores four RGB332 pixels per word. pixel_scale expands each
packed pixel into a square block when decoding. Prefer logical arrays for
new hardware: execution and transfer packing then remain internal.
Batch capture is available through the compiler API for deterministic tests
and snapshot generation. Normal settles use an incremental dependency
worklist; a deterministic full sweep is available as a reference oracle.
Known-address memory writes are grouped by word, while unknown addresses use
a separate conservative path instead of scanning memory for every ordinary
write.