Dev.to WebDev 🛠 Dev 👁 0 📖 11 min read

I built my own roadmap. The first thing it found was that my SR latch didn't work.

Two posts ago I compared my logic simulator to nine others and listed three honest gaps. In the next one I opened the code and discovered that the three gaps were really five items in a dependency graph, two of them invi

Two posts ago I compared my logic simulator to nine others
and listed three honest gaps. In the
next one
I opened the code and discovered that the three gaps were really five items in a
dependency graph, two of them invisible: make time explicit, then waveforms and
step mode; make node identity hierarchical, then sub-circuits; and test cases that
can handle a clock once time exists.

That was the plan. This post is about what happened when I executed it, which is a
different thing, because the plan was drawn by someone who had not yet tried to
build a flip-flop out of two gates.

Boolflow is a browser digital-logic simulator, free, no
account. Everything below is live.

The scoreboard

In the roadmap post What shipped
Explicit tick counter + history Done, but a "tick" turned out to mean something other than I wrote
Step mode + waveform panel Done
Hierarchical node ids by flattening Done
Sub-circuits Done, including editing a block after you placed it
Test cases (sequential half) Done: a C in a column is a clock pulse
"The clock frequency bug" Fixed, and it was worse than I said
"Tell the user it didn't settle" Done, in a form I did not expect
Share link Not done
Truth table to circuit Not done
iframe embed Decided against
Buses Still untouched, as promised

The table is the boring part. The interesting part is everything in it that
needed a footnote.

"Tick" does not mean what I said

I wrote that I needed a monotonically increasing integer owned by the simulator,
and a ring buffer recording the value map at each tick. Both are in. But I
recorded a frame on every evaluation, then watched a waveform fill up with
identical columns while I dragged a gate across the canvas. Moving a node
re-simulates the circuit. Nothing observable changes. Time should not advance.

So a tick is now an event: a frame is stored only when a watched signal
differs from the previous frame. Drag a node, nothing. Flip a clock, a frame.
Throw a switch while the clock is paused, a frame. The panel says so in one line
under its title (each column is one change in the circuit, not a fixed time
step
), because a waveform with no propagation delay is an idealisation and I
would rather say it than have someone discover it.

Step mode fell out the way I hoped. Pause stops the timer; Step runs one tick of
the same schedule the timer would have run. With one clock that is a half-period,
which is where I keep wanting to say "one clock edge" and keep being wrong: you
need to evaluate the falling half too, or the flip-flop never sees the clock go low.

The clock bug was a sleeper

In the last post I showed this line:

const freq = allClocks[0]?.data.freq ?? 1;

Every clock ticked at the frequency of whichever clock came first. I called it a
bug nobody had hit. It was better than that. When I went to fix it I found that
there was no way to set a clock's frequency in the UI at all. freq was
stored, loaded, displayed ("1 Hz") and never editable. The bug was unreachable
from the interface and would have bitten the first person who loaded a file with
two different values.

The fix: one timer at the greatest common divisor of all the clocks' flip periods,
each clock flipping on every n-th tick. Two clocks that coincide flip in the
same render instead of one render apart. And since freq now matters, the clock
node got − and + buttons (0.25 to 8).

The latch that wasn't

This is the part I did not see coming.

I planned to add an oscillation warning, because I had read my own relaxation loop
and noticed it never checked convergence. Ten lines, I said. So I wrote a few
probes to see what it would take to make a circuit oscillate, to have something to
test the warning against. A ring of three inverters. A NAND ring with an enable.

They stayed at ?. Every one of them. A ring of inverters never leaves "unknown",
because NOT(unknown) is unknown, so there is nothing to oscillate.

Then, almost as an afterthought, I wired up the first circuit in every digital
logic course: an SR latch from two cross-coupled NOR gates.

Q  = NOR(R, ~Q)
~Q = NOR(S, Q)

Set it. Reset it. Hold it. ?, for every combination of S and R. My simulator
could not do the first memory circuit anyone learns.

Two causes, both mine.

One. Gates were strict about unknowns. This is the AND gate:

case 'AND': s1(a !== null && b !== null ? a & b : null); break;

If either input is unknown, the output is unknown. But AND(0, ?) is 0, whatever
the question mark turns out to be, and NOR(1, ?) is 0. That is the entire
mechanism by which a cross-coupled pair resolves out of nothing: one gate sees a
controlling input and is decided before the other has an opinion.

const and = vs => (vs.includes(0) ? 0 : vs.every(v => v === 1) ? 1 : null);
const or  = vs => (vs.includes(1) ? 1 : vs.every(v => v === 0) ? 0 : null);

Two. Every call to simulate() started from a blank wire map. A latch holds its
bit in its own outputs, so a blank start means it has nothing to hold. The wire
values now carry over from tick to tick, split into "what the last tick settled
on" and "what this tick is computing", the same way I already treated flip-flop
state.

That second fix has a consequence I had to think about: for a circuit with no
loops, starting from stale values changes nothing, because it settles to the same
answer from any starting point. A test runs six different input pairs
in a row through an XOR and an AND and checks every answer.

Gates have to update together

The relaxation loop also evaluated gates in place, in whatever order the nodes
were listed. With a latch, that makes the order of the file decide the answer.
It now relaxes in rounds: every gate reads a snapshot, all of them update, repeat.
A test reverses the node list and expects identical results.

And now the warning can exist

Release both inputs of a NOR latch from S = R = 1 at the same instant and each
gate sees two zeros, wants to output 1, then sees a 1, wants 0, forever. In real
silicon one gate wins by a few picoseconds and nobody knows which; that is
metastability.
In a zero-delay engine the tie never breaks. After twice-the-node-count rounds the
loop is declared oscillating, its wires are shown as ?, and the status bar says
Does not settle.

The humbling footnote: my first draft of the article claimed you could "release both
together" by clicking two switches. I clicked two switches in the real editor and the latch
landed in a perfectly definite state, because two clicks are two ticks. The only
way to get the oscillation is one switch wired to both inputs. I fixed the article.

The save button that forgot

To make sub-circuits I needed named pins. Named pins come from the labels on
INPUT and OUTPUT elements. I looked for where a user types such a label.

There isn't one.

data.label existed. The truth table read it. The test cases read it. The Verilog,
VHDL and C++ exporters read it, and even sanitised it into a legal identifier in
all three languages. My own landing page said signal names come from the labels you
typed on the canvas
. The only things that ever set it were the built-in examples.

Then I read the save function:

return {
  id: n.id, type: n.data.type, x: n.position.x, y: n.position.y, value: n.data.value,
  // ...PLM fields
};

Type, position, value. A saved circuit came back without its names, without its
clock speed, without its net-label names, and without its frames at all
: a frame
has no data.type, so it was written without a type and could not come back.

The fix is unglamorous. There is now a small name tag under every switch, lamp,
clock, probe and block; you click it and type. And the whole file format moved into
one module with a round-trip test: build a circuit with every kind of node, write it,
read it back, compare. Old files with the lost fields still load.

I am slightly embarrassed that export with your own signal names, a feature I had
been pleased with, was unreachable for anyone who built a circuit by hand.

Sub-circuits: flattening was the right call

I said I would flatten: when you place a block, inline a copy of its nodes into
the parent with every id rewritten to instance/original. The simulator never
learns hierarchy exists.

u1/ff        u2/ff        u1/inner/ff

The engine change was small, and the test that justified the whole exercise is
short. A block containing a toggle flip-flop, placed twice, each copy given its
own clock. Pulse copy A twice and copy B once, and check that A has toggled twice
and B once. Before, both copies would have shared one entry in the state map and
fought over it.

Two things I did not anticipate paid off for free. The path ids make waveform
labels meaningful (DIV2/D.Q, DIV2#2/D.Q) with no extra work. And because a
block is just a circuit, blocks nest, and the exporters, test cases, truth table
and waveform all see through them by calling the same flatten first.

Pins by position, and editing by name

A block's pins are its INPUT and OUTPUT elements, ordered top to bottom as they
sit on the canvas, so the block looks like the drawing it came from.

Editing a placed block was the part I said I would not do, and then did, because a
block you cannot change is a block you redraw. Double-click it and its circuit
opens on the canvas in place of yours; Done writes the change into every copy.

The interesting problem is what to do with the wires. You add a pin at the top and
every other pin's index shifts. If wires followed indices, you would silently
rewire the whole circuit. They follow names: rename a pin and its wire stays;
add a pin and the others move down with their wires; remove one and only its wire
goes. One refinement came from using it: when a name disappears but the number of
pins on that side is unchanged, it is a rename in place, and the wire stays. Renaming
in_0 to CLK is the most common edit there is, and dropping the wire for it would
have been a cruel default.

Tests that can count clock edges

The comparison with Digital had one line I wanted to close: declare inputs and
expected outputs in a table, run, get pass or fail
. Mine could only do
combinational rows. Now a C in an input column is a clock pulse: the signal goes
0, 1, 0, and the outputs are checked after it has settled.

CLK | Q0 Q1 Q2 Q3
C   | 1  0  0  0
C   | 0  1  0  0
C   | 1  1  0  0

A table with a C runs top to bottom as one sequence from power-up, and x in an
input column keeps the last value. It runs on a state of its own and puts the
editor's live state back afterwards, so running a table does not stop the counter
you are watching.

I will say what it does not do: setup and hold times. A pulse is an instant. The
article about it says so.

The 404 that only production had

All of the above was checked in a browser. The landing page got a real screenshot
of the editor, and it was broken on the first deploy: alt text, no image.

The backend serves dist/ from an explicit allow-list: /assets, /donate, a few
root files. A new /img folder fell through to the SPA catch-all, which answers
every unknown path with the app shell and a 404. vite dev and vite preview serve
the whole public/ folder, so I never saw it locally. The fix was one mount; the
lesson is that "works in the preview server" and "works behind my own router" are
different statements. (It also turned out the screenshots were being sent as
application/octet-stream; the Python on the server evidently does not know
.webp, so that got a one-line fix too.)

What is still not there

  • Exporting to Verilog/VHDL/C++ is combinational only. Flip-flops, registers and counters are skipped, and every skipped element is flagged in the output. Tests handle a clock now; the exporters do not.
  • A block nested inside a block is editable only through the outer one.
  • Bus contention (two drivers fighting over a wire) is detected at the top level, not inside a block.
  • No share link and no truth-table-to-circuit synthesis.
  • The 1 Hz on a clock means one flip per second, so the real frequency is 0.5 Hz. I know. Fixing it would make every demo twice as fast, so it stays until I decide what the label should say.

Answers to my own questions

Last time I asked five questions, so here is what I actually did.

  • Flattening versus recursive evaluation. Flattening. The memory cost I worried about never showed up at this scale, and the unplanned benefit was readable path names on the waveform.
  • Idealised waveforms. Kept idealised, and said so on the panel. No fake unit delay.
  • Step granularity. One tick of the shared timer, which is half a clock period with a single clock. I did not build single-gate stepping.
  • Two invisible refactors out of five. In hindsight the invisible ones were the ones that exposed the most. Making time explicit led directly to noticing that the latch did not work.
  • Embeds versus sub-circuits. Sub-circuits. I then decided against the iframe embed entirely.

What I would tell myself before the roadmap post

I wrote a confident dependency graph after reading the simulation entry point and
the state map. That was a good read of two files. It said nothing about the gates,
the save function or the router, and every surprise in this post lived in one of
those.

The numbers, for the record: 282 automated checks across 11 scripts, all passing;
49 articles with a diagram each, in three languages; 32 examples, every one checked
for structure and the sequential ones actually run. The articles I corrected along
the way were checked by running the circuits they describe, which is the habit this
whole stretch taught me.

Boolflow is at boolflow.site: free, no account, nothing
to install. If you teach digital logic or have ever built a latch from NOR gates
and wondered whether a tool would do it, I would like to hear what breaks.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.