LANGUAGE GUIDE10 / 10

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.

Yodl / procedural testbenches
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.

BuiltinFormEffect
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.

Yodl / simulation annotation
@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.

Type to search all chapters.

Reset this example?

Your local edits will be replaced by the original source.

Share this example

The link includes your edits and selected compiler stage. It uses the deployed compiler version.