Simten

How it works

A short tour of Simten's model — primitives, composites, time, and export.

Five things that make Simten different from a textbook simulator. Most of this page is live circuits — read the code, hit play, drill in.

1. Circuits are TypeScript values

A circuit is an object you can read, refactor, and pass to a function. There's no separate HDL file, no synthesis script, no build step. You import it like any other module.

const HalfAdder = circuit('HalfAdder', {
  inputs:  { a: bit, b: bit },
  outputs: { sum: bit, carry: bit },
  nodes:   { xor1: Xor, and1: And },
  connect: ({ inputs, outputs, nodes: { xor1, and1 } }) => [
    inputs.a.to(xor1.a, and1.a),
    inputs.b.to(xor1.b, and1.b),
    xor1.out.to(outputs.sum),
    and1.out.to(outputs.carry),
  ],
});
Compiling...

Toggle the input switches. The output reflects the change immediately because everything here is combinational logic — no clock involved.

2. Composites zoom in

Hardware is recursive. A circuit is built of circuits, which are built of circuits, all the way down to primitives. Double-click any composite node to drill in.

Compiling...

This FullAdder is two HalfAdders and an Or gate. Double-click ha1 and you'll see the same circuit you wrote above — its XOR and AND gates exposed. The inspector keeps zooming as long as there's structure to peel.

3. Sequential vs combinational

Combinational logic answers what — the output is a pure function of the inputs. Sequential logic remembers when — state advances on each clock tick.

Compiling...

Toggle d and hit ▶. The bit shifts one stage per cycle — q1 lags d by one tick, q2 by two. Flip-flops remember; gates don't.

const DelayLine = circuit('DelayLine', {
  inputs:  { d: bit },
  outputs: { q1: bit, q2: bit },
  nodes:   { dff1: DFlipFlop(), dff2: DFlipFlop() },
  connect: ({ inputs, outputs, nodes: { dff1, dff2 } }) => [
    inputs.d.to(dff1.d),
    dff1.q.to(dff2.d, outputs.q1),
    dff2.q.to(outputs.q2),
  ],
});

The DSL is the same shape — inputs, outputs, nodes, connect. Sequentiality comes from which primitives you pick (DFlipFlop, Register, RAM), not from a separate sub-language.

Behind that is a single rule: a primitive is sequential when it declares state. DFlipFlop and Register do; And and Adder don't. There's no sequential flag and no base class — the state is the whole signal. A composite inherits it: your circuit is sequential the moment it holds something that is.

That same declaration hands the circuit a clock. simten has exactly one. Every stateful element subscribes to a single clk the instant it declares state, so you never route it — there's no clock port on Register, no clock in connect. A register's own inputs — data, we, rst — are wires you draw. The clock is a broadcast every register already hears.

So ▶ (or tick()) advances that one clock, and every register latches on the same edge. One clock, one tick, nothing to wire. It's also why simten is single-clock: no second domain, no dividers, no crossings. Export reverses it — the exporter turns that implicit clock back into real clk and rst_n ports for the synthesizer.

4. The trace is the truth

Every value, every wire, every cycle is recorded. When something goes wrong you scrub backward to the exact cycle the bug appeared — no printf, no rerun, no guessing.

The RISC-V CPU debugger is the longest worked example. It runs compiled C through a 5-stage pipeline; every register, every pipeline stage, every cycle is inspectable.

5. Export to Verilog

The circuits you read are the same circuits we cycle-by-cycle verify against Icarus Verilog. Synthesizable circuits export to real Verilog you can flash to an FPGA.

import { exportVerilog } from '@simten/core/verilog';

const { verilog, files } = exportVerilog(HalfAdder, library, { target: 'synthesis' });

The synthesis target strips simulation-only constructs. Memories above 2k words are emitted as $readmemh with the hex contents returned alongside — matching the Yosys/Vivado/Quartus convention.

Every emitted module that contains sequential logic also gets clk and rst_n ports — the exporter auto-plumbs both (your circuit() definitions don't reference clock or reset). rst_n is active-low and synchronous: when held low, registers reset to their value arg, memories preserve their contents, and RV32I register files zero. Your board wrapper must drive both pins.

See Hardware & FPGA for the full pipeline (Yosys → nextpnr → ecppack → flash) using Snake on a ULX3S as the worked example.


Where to go next

On this page