Tamal: The Loader's Three Lives
Yesterday we read two pure step functions and made the same promise about each of them, the promise we have been making all series: the codec describes what one byte does to the state, and someone else owns the register that makes it sequential. Someone else holds the clock. We made that promise for the CRC’s step a month of posts ago, and again for the transmitter and the receiver, and one last time yesterday for cobsDecodeStep and cobsEncodeStep. Five pure s -> i -> (s, o) machines, each admired precisely for not having a clock, each handing the clock off to a someone we kept naming and never met.
Today we meet the someone. The loader is where the clock finally lives.
It is also where everything else we have built finally meets. The receiver and transmitter gave us a byte pipe; the top sealed it and left four wires hanging at the loader’s door. Behind that door is a block that does not compute a residue or drive a line or recover a bit — it conducts. It embeds the COBS codec and threads it through its own clock; it folds the CRC over every byte in both directions; it drives the two block RAMs on the ports the engine leaves free; and it pulses the engine awake and waits for it to halt. The whole series has been building leaves. The loader is the first branch.
Yesterday’s post promised this one in as many words: the loader “wears the silhouette we have now read five times — a sum for its phase, a record for the rest, a step function beside a mealy.” That is exactly right, and exactly the shape of what follows — only scaled up. The sum is now two sums. The record is eighteen fields. And the step is the longest in the series. Same silhouette, far more mass. The mass is worth it, because it buys the loader three distinct lives — listen, run, drain — lived over and over in a loop, and a rig you can load, trigger, read, and load again.
The entire source
Minus the SPDX header and the doc-comments — but this time keeping the inline notes on the state fields, which earn their place — here is src/Tamal/Loader.hs, the longest module in the series:
module Tamal.Loader
( LoaderIn (..)
, LoaderOut (..)
, loader
) where
import Clash.Prelude
import Data.Maybe (fromMaybe, isJust)
import Tamal.Crc (crc8Update)
import Tamal.Loader.Cobs
import Tamal.Params (AW, RW)
data LoaderIn = LoaderIn
{ rxByte :: Maybe (BitVector 8)
, txReady :: Bool
, halted :: Bool
, ringPtrIn :: Unsigned RW
, ringData :: BitVector 32
}
deriving stock (Generic, Show, Eq)
deriving anyclass (NFDataX)
data LoaderOut = LoaderOut
{ txByte :: Maybe (BitVector 8)
, instrWr :: Maybe (Unsigned AW, BitVector 32)
, ringAddr :: Unsigned RW
, startOut :: Bool
}
deriving stock (Generic, Show, Eq)
deriving anyclass (NFDataX)
data Lifecycle = RxControl | Run | Drain
deriving stock (Generic, Show, Eq)
deriving anyclass (NFDataX)
data DrainPhase = DrOpcode | DrFetch | DrLatch | DrWordByte | DrCrcByte | DrDrainOut | DrDelim
deriving stock (Generic, Show, Eq)
deriving anyclass (NFDataX)
data LoaderSt = LoaderSt
{ lPhase :: Lifecycle
, lDec :: DecSt
, lEnc :: EncSt
, lHeld :: Maybe (BitVector 8) -- one-byte holdback (separates the trailing CRC)
, lCrcRx :: BitVector 8 -- running CRC over confirmed bytes
, lHaveOp :: Bool -- opcode confirmed yet?
, lOpcode :: BitVector 8
, lByteIx :: Unsigned 2 -- payload byte within the current word (0..3)
, lWordAcc :: BitVector 32 -- LE word being assembled
, lHadPay :: Bool -- any payload byte seen (TRIGGER must have none)
, lAddr :: Unsigned AW -- next instr write slot
, lFull :: Bool -- instr store overflowed (> 2^AW words)
, lDrn :: DrainPhase
, lWord :: BitVector 32 -- ring word being emitted
, lWIx :: Unsigned 2 -- LE byte of lWord (0..3)
, lCrcTx :: BitVector 8 -- running CRC over the drain
, lDrCnt :: Unsigned RW -- ring record index being fetched
, lTerm :: Bool -- fetching/emitting the terminator word
}
deriving stock (Generic, Show, Eq)
deriving anyclass (NFDataX)
initLoader :: LoaderSt
initLoader =
LoaderSt -- RxControl, both codecs seeded, every counter 0, every flag False
{ lPhase = RxControl
, lDec = initDec
, lEnc = initEnc
, {- … the remaining fifteen fields, all at their zero/False resting value … -}
}
idleOut :: LoaderOut
idleOut = LoaderOut{txByte = Nothing, instrWr = Nothing, ringAddr = 0, startOut = False}
loader :: (HiddenClockResetEnable dom) => Signal dom LoaderIn -> Signal dom LoaderOut
loader = mealy loaderStep initLoader
loaderStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
loaderStep s inp = case lPhase s of
RxControl -> rxStep s inp
Run -> runStep s inp
Drain -> drainStep s inp
runStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
runStep s inp
| halted inp =
( s{lPhase = Drain, lEnc = initEnc, lDrn = DrOpcode, lCrcTx = 0, lDrCnt = 0, lWIx = 0, lTerm = False}
, idleOut
)
| otherwise = (s, idleOut)
rxStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
rxStep s inp =
let din = case rxByte inp of
Just 0 -> (Nothing, True) -- delimiter => frame end
Just b -> (Just b, False)
Nothing -> (Nothing, False)
(dec', (mDec, done, bad)) = cobsDecodeStep (lDec s) din
s1 = s{lDec = dec'}
in if done
then finalize s1 bad
else case mDec of
Nothing -> (s1, idleOut)
Just d ->
let (s2, mw) = case lHeld s1 of
Just h -> confirm s1 h
Nothing -> (s1, Nothing)
in (s2{lHeld = Just d}, idleOut{instrWr = mw})
confirm :: LoaderSt -> BitVector 8 -> (LoaderSt, Maybe (Unsigned AW, BitVector 32))
confirm s h
| not (lHaveOp s) =
( s
{ lHaveOp = True
, lOpcode = h
, lCrcRx = crc8Update (lCrcRx s) h
, lAddr = if h == 0x01 then 0 else lAddr s
, lFull = if h == 0x01 then False else lFull s
}
, Nothing
)
| otherwise =
let crc' = crc8Update (lCrcRx s) h
acc' = lWordAcc s .|. (zeroExtend h `shiftL` (8 * fromIntegral (lByteIx s)))
isLoad = lOpcode s == 0x01
in if lByteIx s == 3
then
let doWrite = isLoad && not (lFull s)
(addr', full') = if lAddr s == maxBound then (lAddr s, True) else (lAddr s + 1, lFull s)
in ( s
{ lCrcRx = crc'
, lWordAcc = 0
, lByteIx = 0
, lHadPay = True
, lAddr = if isLoad then addr' else lAddr s
, lFull = if isLoad then full' else lFull s
}
, if doWrite then Just (lAddr s, acc') else Nothing
)
else
( s{lCrcRx = crc', lWordAcc = acc', lByteIx = lByteIx s + 1, lHadPay = True}
, Nothing
)
finalize :: LoaderSt -> Bool -> (LoaderSt, LoaderOut)
finalize s bad =
let crcCand = fromMaybe 0 (lHeld s)
crcGood = not bad && isJust (lHeld s) && lHaveOp s && lCrcRx s == crcCand
trigOk = crcGood && lOpcode s == 0x02 && not (lHadPay s)
s0 = resetFrame s
in if trigOk
then (s0{lPhase = Run}, idleOut{startOut = True})
else (s0, idleOut)
resetFrame :: LoaderSt -> LoaderSt
resetFrame s =
s
{ lDec = initDec
, lHeld = Nothing
, lCrcRx = 0
, lHaveOp = False
, lOpcode = 0
, lByteIx = 0
, lWordAcc = 0
, lHadPay = False
}
drainStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
drainStep s inp = case lDrn s of
DrOpcode ->
feedByte s inp 0x81 False (\s' -> s'{lDrn = DrFetch, lDrCnt = 0, lTerm = False})
DrWordByte ->
feedByte s inp (leByte (lWord s) (lWIx s)) False (afterWordByte inp)
DrCrcByte ->
feedByte s inp (lCrcTx s) True (\s' -> s'{lDrn = DrDrainOut})
DrFetch ->
let addr = if lTerm s then maxBound else lDrCnt s
(enc', (_, mOut, _)) = cobsEncodeStep (lEnc s) (Nothing, txReady inp)
in (s{lEnc = enc', lDrn = DrLatch}, idleOut{txByte = mOut, ringAddr = addr})
DrLatch ->
let addr = if lTerm s then maxBound else lDrCnt s
(enc', (_, mOut, _)) = cobsEncodeStep (lEnc s) (Nothing, txReady inp)
in ( s{lEnc = enc', lWord = ringData inp, lWIx = 0, lDrn = DrWordByte}
, idleOut{txByte = mOut, ringAddr = addr}
)
DrDrainOut ->
let (enc', (_, mOut, encDone)) = cobsEncodeStep (lEnc s) (Nothing, txReady inp)
in (s{lEnc = enc', lDrn = if encDone then DrDelim else DrDrainOut}, idleOut{txByte = mOut})
DrDelim ->
if txReady inp
then (resetFrame s{lPhase = RxControl}, idleOut{txByte = Just 0})
else (s, idleOut{txByte = Nothing})
where
afterWordByte i s'
| lWIx s' /= 3 = s'{lWIx = lWIx s' + 1}
| lTerm s' = s'{lDrn = DrCrcByte}
| lDrCnt s' + 1 >= ringPtrIn i = s'{lTerm = True, lDrn = DrFetch}
| otherwise = s'{lDrCnt = lDrCnt s' + 1, lDrn = DrFetch}
feedByte ::
LoaderSt -> LoaderIn -> BitVector 8 -> Bool -> (LoaderSt -> LoaderSt) -> (LoaderSt, LoaderOut)
feedByte s inp b lst advance =
let (enc', (readyIn, mOut, _)) = cobsEncodeStep (lEnc s) (Just (b, lst), txReady inp)
s1 = s{lEnc = enc'}
s2 =
if readyIn
then advance s1{lCrcTx = if lst then lCrcTx s1 else crc8Update (lCrcTx s1) b}
else s1
in (s2, idleOut{txByte = mOut})
leByte :: BitVector 32 -> Unsigned 2 -> BitVector 8
leByte w i = case i of
0 -> slice d7 d0 w
1 -> slice d15 d8 w
2 -> slice d23 d16 w
_ -> slice d31 d24 wA dozen things, top to bottom: a three-name export list; the two I/O records LoaderIn and LoaderOut; two little sums, Lifecycle and DrainPhase; the eighteen-field LoaderSt that is the machine’s whole memory; two initialisers; the mealy one-liner and the loaderStep that dispatches on phase; and then the three step functions with their helpers. We wave past the export list and imports, slow right down for the types, and walk the machine one life at a time.
The ritual, skipped
You know this opening beat cold. A module header with its export list; the import Clash.Prelude prelude swap; the deriving stock (Generic, Show, Eq) and deriving anyclass (NFDataX) refrain stamped on every type so Clash can put it in a register. Six posts have paid that toll in full — the CRC and primer posts derived it at length — and I will not charge it a seventh time. Everyone already knows what those are about.
The one line worth stopping on is not ritual at all. It is the three imports in the middle:
import Tamal.Crc (crc8Update)
import Tamal.Loader.Cobs
import Tamal.Params (AW, RW)The CRC fold, the COBS codec, and the shared address widths. The loader imports the very blocks the last six posts built and puts them all to work at once. This is the confluence in three lines: read them and you already know the loader will fold a CRC, stream a COBS codec, and address two memories sized by AW and RW. Everything we have read plugs in right here.
Params, since it has nowhere else to live
One of those imports has no post of its own, and never will: Tamal.Params is two type aliases and a doc-comment, and two lines do not earn a post of their own. So we settle the small debt here, once, where the loader first leans on them. In its entirety, minus the ritual:
type AW = 10 :: Nat -- instruction-address width: 2^AW = 1024 words
type RW = 12 :: Nat -- ring/trace-address width: 2^RW = 4096 wordsThat is the whole module. AW is the instruction-address width — 2^AW = 1024 words in the instruction store, and the program counter’s width to match. RW is the ring/trace-address width — 2^RW = 4096 words in the result ring, whose top slot is the reserved HALT terminator the drain fetches last. The point is not the two numbers but the sharing: Tamal.Params is a dependency-free leaf, importing only Clash.Prelude, so the engine, the two BRAM wrappers, the trace model, and the loader can all name the same widths without importing one another — and widening the instruction space or resizing the ring becomes a single edit here instead of a hunt for hand-copied Unsigned 10 and Unsigned 12 literals scattered across the tree. The loader meets both in a moment: lAddr :: Unsigned AW counts instruction slots, ringAddr :: Unsigned RW sweeps the ring. Debt paid; on to the types that do earn their keep.
The types are the design
The primer’s sum-and-product story, told one final time — and told carefully, because with the loader the types are the design. Read the five of them and the machine is most of the way explained; the step functions largely just honour what the records already promise. We take them in the order they appear.
The ports: LoaderIn and LoaderOut
data LoaderIn = LoaderIn
{ rxByte :: Maybe (BitVector 8)
, txReady :: Bool
, halted :: Bool
, ringPtrIn :: Unsigned RW
, ringData :: BitVector 32
}
data LoaderOut = LoaderOut
{ txByte :: Maybe (BitVector 8)
, instrWr :: Maybe (Unsigned AW, BitVector 32)
, ringAddr :: Unsigned RW
, startOut :: Bool
}These two records are the seam. The top left four wires hanging at the loader’s door; here they are, named and typed, sitting beside the wires that reach the other way, to the engine and the two memories. Read LoaderIn as “everything the world hands the loader each cycle”:
rxByte— a byte from the UART receiver, orNothing. A one-cycle strobe:Just bon the cycle a byte lands,Nothingthe ~500 cycles in between.txReady— the UART transmitter is idle and can take a byte. Back-pressure for the drain.halted— the engine’shaltedOut, a level: high for as long as the engine sits in itsHaltedphase.ringPtrIn— how many trace records the engine wrote. The drain’s upper bound, read straight off the halted engine.ringData— the ring BRAM’s read data, arriving one cycle after the loader drives an address.
And LoaderOut is “everything the loader drives back”:
txByte— a byte for the UART transmitter, orNothing.instrWr— a write to the instruction store:Just (addr, word)or nothing this cycle.ringAddr— the ring BRAM read address the drain sweeps.startOut— the one-cycle pulse that wakes the engine.
Two details hide in the signatures. First, the Unsigned AW and Unsigned RW we just met surface here as concrete port widths — instrWr’s address into the 1024-word instruction store, and ringAddr/ringPtrIn into the 4096-slot ring. Second, and quietly load-bearing: the loader drives exactly two memory ports, the instruction write and the ring read, and never the two the engine owns. It writes only while loading and reads only while draining, never overlapping the engine’s run — so the two machines share two BRAMs with no arbiter at all, collision-free by construction.1
Lifecycle: the three lives
data Lifecycle = RxControl | Run | DrainThree constructors, and the whole post’s title. The loader is never doing more than one of these at a time, and it moves between them in a fixed loop:
RxControl— listen. The only life that consumes the UART’s receive strobe. Decode each incoming control frame, load programs into the instruction store as they arrive, and watch for a trigger.Run— stand aside. The engine is executing. The loader drives nothing, listens to nothing, and watches a single bit:halted.Drain— report. The engine has halted; sweep its trace ring out the transmitter as one frame, then go back to listening.
The entire top of the machine is a case on this type. Load, run, drain, and back to load — three lives lived in a ring, re-runnable forever: a later trigger re-runs the same program, a later load replaces it.RxControl is the resting state and the only one that consumes the UART receiver; a valid LOAD_PROGRAM writes its words and stays put (self-loop), while a valid TRIGGER pulses startOut and steps to Run. Run watches the engine's halted level and, when it goes high, arms Drain. Drain sweeps the trace ring out the UART and returns to RxControl — so the rig is re-runnable, load/trigger/drain, round and round.
DrainPhase: a program counter for the sweep
data DrainPhase = DrOpcode | DrFetch | DrLatch | DrWordByte | DrCrcByte | DrDrainOut | DrDelimLifecycle has three states because the loader has three jobs. Drain, though, is not one action but a little sequence — emit an opcode, then fetch a ring word, then wait a cycle for the memory to answer, then emit its four bytes, then loop, then emit a CRC, then flush, then cap the frame — and this seven-constructor sum is that sequence’s program counter. The Drain life runs a tiny program, and lDrn is where in the program it is.
Why spell a sequence out as seven named states instead of a loop? Because in hardware a loop is a state machine, and every step of this one is a place the loader might have to wait: a BRAM read does not answer until the next cycle, the UART will not take a byte unless txReady, and the COBS encoder buffers a whole group before it emits. Each stall needs a named state to hold in and come back to. We walk the seven in the drain section; for now note the shape — a fetch/latch pair for the memory’s latency, a per-byte emit state, and three states to close out the frame.
LoaderSt: eighteen fields, four records in a trench coat
Here is the field that everyone flinches at:
data LoaderSt = LoaderSt
{ lPhase :: Lifecycle
, lDec :: DecSt
, lEnc :: EncSt
, lHeld :: Maybe (BitVector 8) -- one-byte holdback (separates the trailing CRC)
, lCrcRx :: BitVector 8 -- running CRC over confirmed bytes
, lHaveOp :: Bool -- opcode confirmed yet?
, lOpcode :: BitVector 8
, lByteIx :: Unsigned 2 -- payload byte within the current word (0..3)
, lWordAcc :: BitVector 32 -- LE word being assembled
, lHadPay :: Bool -- any payload byte seen (TRIGGER must have none)
, lAddr :: Unsigned AW -- next instr write slot
, lFull :: Bool -- instr store overflowed (> 2^AW words)
, lDrn :: DrainPhase
, lWord :: BitVector 32 -- ring word being emitted
, lWIx :: Unsigned 2 -- LE byte of lWord (0..3)
, lCrcTx :: BitVector 8 -- running CRC over the drain
, lDrCnt :: Unsigned RW -- ring record index being fetched
, lTerm :: Bool -- fetching/emitting the terminator word
}Eighteen fields is a lot, and the honest reaction is a wince. But it is not eighteen unrelated things; it is four small records wearing one constructor, because a mealy carries exactly one state value and the loader would rather be one machine with one memory than four machines haggling over turns. Sort the fields by which job they serve and the wince goes away:
- Phase (1 field).
lPhase— which of the three lives we are living. The one field every cycle reads first. - Embedded codecs (2 fields).
lDecandlEnc— the COBS decoder’sDecStand the encoder’sEncSt, held inside the loader’s state. This is the promise the last post made, kept to the letter: the codec exported its two opaque state types and theirinitDec/initEncseeds precisely so the loader could carry them here and thread them through its own clock. “The loader is the machine that clocks them” — these two fields are where. - RX / frame-parse (9 fields). The load path’s scratchpad:
lHeld(the one-byte holdback),lCrcRx(running CRC over confirmed bytes),lHaveOpandlOpcode(have we seen the opcode, and what was it),lByteIxandlWordAcc(which of a word’s four bytes we are on, and the little-endian word taking shape),lHadPay(did any payload arrive — aTRIGGERmust have none), andlAddrandlFull(the next instruction slot, and whether the store overflowed). - Drain (6 fields). The report path’s scratchpad:
lDrn(theDrainPhaseprogram counter),lWordandlWIx(the ring word being emitted and which of its bytes),lCrcTx(running CRC over the drain),lDrCnt(which ring record we are fetching), andlTerm(are we on the terminator word yet).
Read that way it is four modest records, and the two big ones — RX-parse and Drain — are never live at once, since RxControl and Drain are different lives. At any instant half these fields are asleep. They cohabit one record for the reason the whole loader is one mealy: one machine with one memory is simpler to reason about, and to register, than three sharing a bus.
The two one-liners
initLoader is the machine at power-on — RxControl, both codecs seeded with initDec/initEnc, every counter zero, every flag false: listening, nothing held, nothing owed. idleOut is the do-nothing output — no byte, no write, no pulse — and it earns a name because the loader returns it on the overwhelming majority of cycles. Naming it once lets every step function say “nothing happens this cycle” in five characters, and lets your eye skip to the cycles that matter.
The machine that owns the clock
loader :: (HiddenClockResetEnable dom) => Signal dom LoaderIn -> Signal dom LoaderOut
loader = mealy loaderStep initLoader
loaderStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
loaderStep s inp = case lPhase s of
RxControl -> rxStep s inp
Run -> runStep s inp
Drain -> drainStep s inpHere is the promise kept. Six posts of pure steps insisting someone else owns the register, and this is the someone. mealy is the Clash primitive the transmitter introduced: hand it a pure s -> i -> (s, o) and a seed, and it wraps exactly one register around the state and lifts the pure function into Signal dom i -> Signal dom o — a clocked thing. loader hands mealy its loaderStep and initLoader, and out comes a signal function with a clock threaded through it. Every pure step we praised for having no clock — the CRC’s, the codec’s two — gets its clock here, because the loader clocks itself and carries them along in lDec, lEnc, lCrcRx, lCrcTx.2
And loaderStep is almost nothing: read the current life off lPhase and delegate. That case is the whole top-level control flow — the three lives are three functions, and the machine is whichever the phase names. So the rest of the post is three smaller ones; we take the easiest first.
Run: the life that waits
runStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
runStep s inp
| halted inp =
( s{lPhase = Drain, lEnc = initEnc, lDrn = DrOpcode, lCrcTx = 0, lDrCnt = 0, lWIx = 0, lTerm = False}
, idleOut
)
| otherwise = (s, idleOut)The shortest of the three, and the only one that does essentially nothing. While the engine runs, the loader has exactly one job: watch one bit. halted is a level, not a pulse — the engine’s Halted phase is stable, it does not blink — so the guard just tests it every cycle. Low: hold the state, emit idleOut. High: the run is over, so arm the drain and switch lives.
Arming the drain is that record update, worth reading for what it spares. It seeds the encoder fresh, rewinds the program counter to DrOpcode, and zeroes the drain counters and CRC — but pointedly does not touch lAddr, lFull, or the program in the instruction BRAM. A drain must not disturb the loaded code, because the rig is re-runnable: a later TRIGGER with no new LOAD re-runs it. Run is a turnstile — it spins until halted, then clicks one notch into Drain.
RxControl: the load path
RxControl is the loader listening. A control frame arrives on the UART one byte at a time, and the job is to turn that trickle back into a message — an opcode and its payload — verify it, and act. Before the code, the thing being parsed. A frame on the wire looks like this, and so does the frame the drain will later build in the other direction:CRC-8 byte guards it; COBS stuffs every zero out of the opcode-payload-CRC run; and a lone 0x00 delimiter closes the frame. The last post built the COBS layer. This post builds the two layers outside it — and peels them, on the way in.
Read from the inside out. The innermost thing is the message — an opcode byte and its payload, the logical frame Tamal.Wire’s encodeControl builds before it wraps it. Around it, a CRC-8 byte guards the lot. Around that, COBS stuffs out every zero, so the run holds no interior 0x00. And around everything, a single 0x00 delimiter. The loader’s receive job is to peel those layers in reverse: watch for the delimiter, un-COBS the interior, check the CRC, and read the message. Here is the whole of it.
rxStep :: LoaderSt -> LoaderIn -> (LoaderSt, LoaderOut)
rxStep s inp =
let din = case rxByte inp of
Just 0 -> (Nothing, True) -- delimiter => frame end
Just b -> (Just b, False)
Nothing -> (Nothing, False)
(dec', (mDec, done, bad)) = cobsDecodeStep (lDec s) din
s1 = s{lDec = dec'}
in if done
then finalize s1 bad
else case mDec of
Nothing -> (s1, idleOut)
Just d ->
let (s2, mw) = case lHeld s1 of
Just h -> confirm s1 h
Nothing -> (s1, Nothing)
in (s2{lHeld = Just d}, idleOut{instrWr = mw})Four moves, top to bottom. The delimiter watch comes first: that case rxByte. A received 0x00 is not data — COBS guarantees no interior zeros, so a zero on the wire can only be the frame boundary — so the loader translates it into the codec’s vocabulary: Just 0 becomes (Nothing, frameEnd = True), any other byte (Just b, False), no byte (Nothing, False). That frameEnd pulse is exactly the one cobsDecodeStep expects, and here is the thing that pulses it. The delimiter belongs to the frame layer, not the codec, and this case is where that line is drawn.
Second, feed the codec. cobsDecodeStep runs, threaded through lDec, and hands back the triple we read last post: a maybe-decoded byte mDec, a done pulse, a bad flag.
Third, if done — the delimiter fired — the frame is over; hand off to finalize. If instead the codec produced nothing this cycle (Nothing), idle; that is the common case, a byte mid-flight or a code byte that only armed a counter.
And fourth, the move that deserves its own name. When a decoded byte d does arrive, the loader does not process it. It processes the byte it was already holding, lHeld, and stashes d in its place. That is the holdback, and it exists to solve one specific problem:
The last byte of the logical stream is the CRC. But nothing marks it as the CRC while it streams — it looks like any other byte, right up until the delimiter proves it was the last one. So the loader always lags one byte behind: it holds the newest decoded byte and only confirms the previous one, because only a byte with another byte behind it is provably payload, not the trailing check.
rxByte → delimiter-watch → COBS decode → holdback → confirm. Because the trailing byte of the logical stream is the CRC — indistinguishable from payload until the frame ends — the loader confirms each byte only when the next one arrives. The final byte is never confirmed: it is the CRC candidate, held back and checked against the running CRC when the 0x00 delimiter closes the frame.Same field, two fates: when a new byte arrives, the held one is provably not the last, so confirm works on it; when the delimiter arrives, the held byte is the last, so finalize treats it as the CRC. What comes next decides.3
confirm: fold, route, assemble, write through
confirm receives a byte h that has just been proven payload — a byte arrived behind it — and does the frame’s real bookkeeping (its full text is in the listing above). Two cases split on whether we have an opcode yet.
The first confirmed byte is the opcode. Record it in lOpcode, set lHaveOp, and fold it into the running CRC. Then one special touch: if the opcode is 0x01, LOAD_PROGRAM, reset the write address to 0 and clear the overflow flag, so a load always writes from the top of the store. No output byte — an opcode writes nothing to memory.
Every later confirmed byte is payload. Fold it into the CRC, then shift it into lWordAcc at the current byte position: zeroExtend h `shiftL` (8 * byteIx). That is little-endian assembly by construction — byte 0 lands in bits 7:0, byte 1 in 15:8, and so on. When lByteIx reaches 3 a full 32-bit word is complete, and this is where the load actually touches memory:
if lByteIx s == 3
then
let doWrite = isLoad && not (lFull s)
(addr', full') = if lAddr s == maxBound then (lAddr s, True) else (lAddr s + 1, lFull s)
in ( s{ lCrcRx = crc', lWordAcc = 0, lByteIx = 0, lHadPay = True
, lAddr = if isLoad then addr' else lAddr s
, lFull = if isLoad then full' else lFull s }
, if doWrite then Just (lAddr s, acc') else Nothing )
else
( s{lCrcRx = crc', lWordAcc = acc', lByteIx = lByteIx s + 1, lHadPay = True}, Nothing )The completed word is emitted as Just (lAddr s, acc') — a write to the instruction BRAM at the current slot — provided this is a LOAD and the store has not overflowed. Then the address bumps (saturating at maxBound — 1023 for Unsigned AW — and latching lFull rather than wrapping), the accumulator and byte index reset, and lHadPay records that payload was seen. A word that is not a load, or that lands past the 1024-word cap, just advances the state and writes nothing.
Notice that the write to the instruction BRAM happens here, mid frame, before the CRC is ever checked. The design calls it write-through: rather than buffer the program to commit it atomically, the loader scribbles each word into the store the instant it assembles. A frame that later fails its CRC has already dirtied memory — and that is fine, because loading and triggering are separate frames: nothing runs until a good TRIGGER, and a bad LOAD is overwritten by the host’s retry before any trigger ever fires.4
finalize: the delimiter’s verdict
finalize :: LoaderSt -> Bool -> (LoaderSt, LoaderOut)
finalize s bad =
let crcCand = fromMaybe 0 (lHeld s)
crcGood = not bad && isJust (lHeld s) && lHaveOp s && lCrcRx s == crcCand
trigOk = crcGood && lOpcode s == 0x02 && not (lHadPay s)
s0 = resetFrame s
in if trigOk
then (s0{lPhase = Run}, idleOut{startOut = True})
else (s0, idleOut)The delimiter fired, so the held byte is not payload — there is no byte behind it — which makes it the CRC candidate, crcCand. Now the verdict, in two conjunctions.
crcGood demands four things at once: the codec did not report the frame bad (no truncated group, no overshoot); a byte was actually held (a frame with nothing in it holds Nothing); an opcode was seen; and the CRC folded over every confirmed byte equals the candidate. Miss any one and the frame is junk. trigOk narrows further: a good frame whose opcode is 0x02, TRIGGER, carrying no payload. Only that pulses the engine.
Then the machine resets its frame-parse state either way (resetFrame clears the codec, holdback, CRC, opcode, and word assembly — but not lAddr/lFull, already committed by the write-through) and branches. A good trigger flips to Run and pulses startOut for one cycle. Anything else — a good LOAD (words already in memory), a bad frame, an unknown opcode — resets and stays in RxControl, silent. That silence is the whole error policy: the loader never NAKs, never raises an error frame; a bad frame simply has no effect, and the host, hearing nothing, times out and re-sends.5
Watching a TRIGGER land
Take the smallest interesting frame — a TRIGGER — and feed it byte by byte. Its logical stream is one opcode, 0x02, plus its CRC-8, which for the single byte 0x02 works out to 0x0E. COBS wraps the pair 02 0E (no zeros) as a single group 03 02 0E, and the frame layer caps it with the delimiter. On the wire: 03 02 0E 00.
03— delimiter-watch passes(Just 3, False);cobsDecodeStepreads a code byte, arms its counter, emits nothing.lHeldis stillNothing. Idle.02— the decoder emits the data byte02.lHeldisNothing, so nothing is confirmed yet; stashlHeld = Just 02. Idle.0E— the decoder emits0E. NowlHeldholds02, soconfirmruns on it: it is the first byte, solOpcode = 0x02,lHaveOp = True, andlCrcRx = crc8Update 0 0x02 = 0x0E. Then stashlHeld = Just 0E. Idle. The opcode is confirmed; the CRC byte is held.00— delimiter.cobsDecodeSteppulsesdone, notbad.finalize:crcCand = 0E;crcGoodisTruebecauselCrcRx = 0Eequals it, an opcode was seen, a byte was held, nothing was malformed.trigOkisTrue— opcode0x02, no payload. PulsestartOut, step toRun.
Read the drama in the last two steps. The held 0E was never confirmed as payload — it had no byte behind it — so it stayed the candidate and was checked, not folded. The opcode 02 was confirmed, at the exact moment 0E arrived to prove it was not the last byte. The holdback did its one job perfectly: the opcode got folded, the CRC got checked, and the pulse fired after the delimiter, never a cycle before the frame was known good. Corrupt any byte of 03 02 0E 00 and step 4’s crcGood collapses; no pulse, no run — exactly what a fire-and-forget link wants.
Drain: the result path
Drain is the mirror image. Where RxControl peeled a frame off the wire, Drain builds one and pushes it out — the result frame from the diagram above: opcode 0x81, then the ring’s words as little-endian bytes (the REVISION at word 0, the engine’s trace records, and the HALT terminator at the very top of the ring), then a CRC, all COBS-encoded and capped with a delimiter.
But the drain is harder than the load, and for a nameable reason: three different things can make it wait. A BRAM read takes a cycle to answer. The UART accepts a byte only when txReady. The COBS encoder buffers a whole group before it emits. Every one of those is a stall, and a stall needs a state to wait in and return from. That is why Drain is not a loop but the seven-state sub-machine DrainPhase named earlier — one state per place the sweep might have to pause.DrOpcode emits 0x81; the accent DrFetch/DrLatch pair drives a ring address and captures the data one cycle later; DrWordByte emits the word's four little-endian bytes and loops back to DrFetch for each further record; DrCrcByte emits the drain CRC flagged last; DrDrainOut clocks the encoder until its final group is flushed; and DrDelim appends the lone 0x00 and returns to RxControl. Every emitted byte waits for txReady — the self-pacing a flow-control-less link leans on.
The sub-machine is one case on lDrn, shown in full in the listing above. Three of its arms — DrOpcode, DrWordByte, DrCrcByte — are one-liners that hand a logical byte to feedByte (next section); the other four move memory and manage the encoder. Its only real branching is the where-helper that decides where to go after each emitted word byte:
afterWordByte i s'
| lWIx s' /= 3 = s'{lWIx = lWIx s' + 1}
| lTerm s' = s'{lDrn = DrCrcByte}
| lDrCnt s' + 1 >= ringPtrIn i = s'{lTerm = True, lDrn = DrFetch}
| otherwise = s'{lDrCnt = lDrCnt s' + 1, lDrn = DrFetch}Walk the seven, remembering that the three feeding phases produce the logical stream while the other four move memory and manage the encoder.
DrOpcode— feed the opcode0x81; when the encoder takes it, advance toDrFetchand zero the record counter. The frame begins.DrFetch— drive the ring read address (lDrCnt, ormaxBoundfor the terminator) ontoringAddr. The BRAM will answer next cycle, so this state does nothing else but keep the encoder turning (fedNothing) in case it still has buffered bytes to emit. Advance toDrLatch. This is the address half of a read.DrLatch— the BRAM’s answer is onringDatanow; latch it intolWord, reset the byte cursorlWIx = 0, advance toDrWordByte. The data half. Fetch-then-latch, two states, is exactly the block RAM’s one-cycle read latency written out — a contract the two memories get their own post next to explain, honoured here as a pair of phases.DrWordByte— feed the current little-endian byte oflWord(vialeByte) to the encoder; on consume, runafterWordByte. That helper is the drain’s only real branching: if bytes remain in this word (lWIx /= 3), bump the cursor; else if this was the terminator, go toDrCrcByte; else if the record just fetched was the last one (lDrCnt + 1 >= ringPtrIn), setlTermand loop toDrFetchfor the terminator atmaxBound; else bumplDrCntand loop toDrFetchfor the next record. That is the accent loop in the diagram.DrCrcByte— feed the accumulated drain CRClCrcTx, flaggedlastso the encoder knows the logical stream has ended and can flush its final group. Advance toDrDrainOut.DrDrainOut— no more input; just clock the encoder (Nothing) until it pulsesencDone, meaning its last buffered group is out. ThenDrDelim.DrDelim— append the one0x00frame delimiter, paced ontxReady, reset the frame state, and return toRxControl. The frame is complete; the loader is listening again.
One quiet elegance is worth pausing on. DrFetch and DrLatch re-use lWord for every record, overwriting it each time — safe, because by the time the machine leaves DrWordByte all four of the old word’s bytes are already inside the encoder’s buffer. The word register is a one-word window; the encoder is the real buffer, and its 254-byte look-ahead decouples the fast BRAM sweep from the far slower UART emit.
feedByte: one byte to the encoder
feedByte ::
LoaderSt -> LoaderIn -> BitVector 8 -> Bool -> (LoaderSt -> LoaderSt) -> (LoaderSt, LoaderOut)
feedByte s inp b lst advance =
let (enc', (readyIn, mOut, _)) = cobsEncodeStep (lEnc s) (Just (b, lst), txReady inp)
s1 = s{lEnc = enc'}
s2 =
if readyIn
then advance s1{lCrcTx = if lst then lCrcTx s1 else crc8Update (lCrcTx s1) b}
else s1
in (s2, idleOut{txByte = mOut})feedByte is the drain’s workhorse, and the exact dual of the receive side’s fold-and-route. It presents one logical byte b to cobsEncodeStep along with its last flag and the downstream-ready line txReady. The encoder answers with readyIn (did it take the byte?), mOut (a COBS byte to send, maybe), and a done flag we ignore here.
If readyIn — the byte was consumed — fold it into the drain CRC (unless it is the CRC byte itself, flagged lst; you do not CRC the CRC) and run the advance continuation to step the phase. If not consumed — the encoder is mid-group, or txReady is low — hold: same byte, same phase, next cycle. Either way, route mOut to txByte. So every output byte is gated on txReady, and the generator runs exactly as fast as the encoder consumes, which runs exactly as fast as the UART drains.5 The separation is the pretty part: the three feeding phases decide which byte comes next; feedByte owns how to hand it over and when to advance. Three call sites, one rule.
leByte: the little-endian tap
leByte :: BitVector 32 -> Unsigned 2 -> BitVector 8
leByte w i = case i of
0 -> slice d7 d0 w
1 -> slice d15 d8 w
2 -> slice d23 d16 w
_ -> slice d31 d24 wThe trivial helper, listed for symmetry: byte i of a 32-bit word, little-endian — slice d7 d0 is the low byte, up to slice d31 d24 the high. It is the exact inverse of the receive side’s lWordAcc `shiftL` (8 * byteIx): the load packs four little-endian bytes into a word, the drain unpacks a word into four. The wire is little-endian in both directions, and these two lines are the two ends of that one agreement.
Watching the minimal drain
The smallest possible drain is a ring with no records at all — just the REVISION word and the terminator. Say ringPtrIn = 1, ring[0] = 0x0001_0000 (REVISION), and the terminator at the top of the ring is 0xC000_0000. The logical stream the phases produce should be 0x81, then the four little-endian bytes of each word, then the CRC:
DrOpcode— feed0x81; consumed, so fold it intolCrcTx, go toDrFetch,lDrCnt = 0.DrFetch— driveringAddr = 0. →DrLatch.DrLatch— latchlWord = 0x0001_0000,lWIx = 0. →DrWordByte.DrWordByte×4 — feed00 00 01 00, the LE bytes of0x0001_0000. After the fourth,lWIxwas3, not the terminator, andlDrCnt + 1 = 1 >= ringPtrIn = 1, so setlTermand loop toDrFetch.DrFetch(terminator) — driveringAddr = maxBound. →DrLatch.DrLatch— latchlWord = 0xC000_0000,lWIx = 0. →DrWordByte.DrWordByte×4 — feed00 00 00 C0. After the fourth,lWIx = 3andlTermis set, so →DrCrcByte.DrCrcByte— feedlCrcTx, the CRC folded over0x81and all eight word bytes, flaggedlast. →DrDrainOut.DrDrainOut— clock the encoder untilencDone. →DrDelim.DrDelim— emit0x00, reset, →RxControl.
The logical stream handed to the encoder is 81 · 00 00 01 00 · 00 00 00 C0 · crc — ten bytes, and notice how thoroughly zero-riddled it is. A real ring word is full of 0x00, which is exactly why COBS is in this pipeline at all: the encoder stuffs every one of those zeros out, the loader caps the result with a single 0x00, and the whole thing comes out equal, byte for byte, to Tamal.Wire’s pure encodeResult [0x0001_0000, 0xC000_0000]. Which is precisely what the test suite asserts — so let us turn to it.
The tests
The loader is impure, so unlike the pure COBS steps you cannot apply it to a value and read the answer — you build a Signal, sampleN it, and compare. But the discipline is the COBS post’s, up one layer: a pure reference is the oracle, and the streaming machine is held to it byte for byte. Where COBS checked itself against pure Tamal.Wire.Cobs, the loader checks its whole frame layer against pure Tamal.Wire — encodeControl and encodeResult, the list-to-list functions that are the wire format.
The load path tests feed the exact bytes of encodeControl (LoadProgram ws) and assert the write stream:
testProperty "LOAD_PROGRAM writes the exact words at 0,1,2,.." $ property $ do
ws <- forAll (Gen.list (Range.linear 0 20) genWord)
let bytes = encodeControl (LoadProgram ws)
simInstrWr (fmap Just bytes) === [(fromIntegral i, w) | (i, w) <- L.zip [0 :: Int ..] ws]For random programs, the words land at 0, 1, 2, … with the right values — the write-through, the little-endian assembly, and the holdback all proven in one line. A TRIGGER pulses startOut exactly once and only after the frame; a LOAD pulses it never. There is one lovely detail in the harness: every stimulus is led by an idle cycle, because sampleN asserts the reset on cycle 0 and a byte fed then would be lost — exactly as it would be lost in hardware, where the line idles before the first frame. The test models the reset hazard rather than papering over it.
The drain tests are the clever ones, because the drain reads a memory the test must supply. The rig closes the ring-BRAM loop with a register for the one-cycle latency and a pure ringModel as the memory:
drainRig lookupRing ringPtrV rxs txr hlt = txByte <$> loaderOut
where
loaderOut = loader loaderIn
ringDataS = register 0 (lookupRing <$> (ringAddr <$> loaderOut))
loaderIn = LoaderIn <$> rxs <*> txr <*> hlt <*> pure ringPtrV <*> ringDataSThat register 0 (lookupRing <$> ringAddr) is the BRAM: the loader’s own ringAddr output, delayed one cycle and looked up in the model, becomes its ringData input — the fetch/latch dance closed into a feedback loop. On top of it, one property drives a full lifecycle — TRIGGER, Run, halted, Drain — and demands the drained bytes equal the oracle:
testProperty "drain stream == encodeResult (records ++ terminator)" $ property $ do
records <- forAll (Gen.list (Range.linear 1 24) genWord)
term <- forAll genWord
simDrain records term [True] === encodeResult (records <> [term])And then the test that justifies the entire no-flow-control design: run the same drain with txReady chopped to a stuttering [True, False, True, True, False] and assert the output is byte-identical. If the self-pacing has a leak — a dropped byte, a duplicated one — this goes red. It stays green, which is the proof that the loader may share an unpaced link with a host and never corrupt a frame.
The robustness cases round it out, each one a design decision made falsifiable: flip any single bit of a TRIGGER and no run ever starts (property over the flip position); over-load 1100 words and the addresses saturate at 1023 with exactly 1024 writes; send two LOADs and each writes from address 0 (the overwrite that makes a failed load harmless); trigger-halt twice and the ring drains twice (re-runnable). The shape is the house style, now familiar: a pure model that is the meaning, a streaming machine that is the silicon, and property tests that weld the two together over a fresh shower of inputs every run.6
What we read
Three lives, one clock. RxControl listens, peeling a frame layer by layer and holding one byte back so the trailing CRC never masquerades as payload, writing each little-endian word straight through to the instruction store and committing nothing until a separate TRIGGER says go. Run waits on a single bit. Drain sweeps the ring out in seven patient phases, dancing with the memory’s latency, folding a CRC, flushing the encoder, and pacing every byte off txReady so an unpaced link never overruns. One mealy, one eighteen-field memory that is really four small ones, one case on the phase.
And inside it, everything the series built. The COBS codec, threaded through the loader’s clock in lDec and lEnc — the promise that post made, kept. The CRC, folded over both directions in lCrcRx and lCrcTx. The UART’s four wires, finally connected. The two block RAMs, driven on the ports the engine leaves free. The clock that six pure steps kept deferring to “someone else” lives here, and the someone has a name.
What the loader loads, triggers, and drains is a program — run by a machine we have circled for the entire series and never once opened. The instruction store the loader fills, the trace ring it sweeps, the The collision-free claim is the block-RAM design’s port-ownership contract cashed in. The design doc reached for The one-byte holdback is a general streaming idiom worth naming, because it recurs anywhere a stream’s last element is special and unmarked. You cannot know an element is the last until the stream ends, so if the last one needs different treatment — a checksum, a terminator, a flush — you buffer exactly one and always act on the previous, then handle the straggler when the end arrives. It is the streaming dual of Write-through trades atomicity for a BRAM. The alternative — buffer the whole program, check its CRC, and commit only on success — needs somewhere to hold up to 1024 words, a second four-kilobyte block RAM, to guard against a failure the protocol already handles. Because The Arty A7’s FTDI USB-UART has no RTS/CTS wired to the FPGA — a board fact, not a choice — so there is no hardware flow control in either direction, and the loader carries none to match. It survives on three things: the receive side cannot overrun (per-byte work is a handful of cycles against a ~500-cycle byte period at 2 Mbaud); the drain self-paces off The same two-implementations pattern the COBS post called the house style, now one layer up. Down there it was pure halted it waits on and the startOut it pulses: those are all the engine’s, and the engine is the last door. We open it soon — but first, a shorter breath: the two memories the loader has spent this post driving, and that the engine is about to live between, deserve a look of their own. Then, at last, we walk through the door.Footnotes
RxControl/Drain and Run are different lives, never concurrent, so the loader (instruction write, ring read) and the engine (instruction read, ring write) touch disjoint ports at disjoint times. The schedule is the arbitration — which is what lets the loader be one more mealy beside the engine rather than a bus master negotiating for access. ↩mealyS — the State-monad flavour of mealy, where the transition is written in do-notation over State s rather than as an explicit s -> i -> (s, o) — and flagged it as “the idiom for exactly this long sequential FSM.” The shipped code uses plain mealy instead, “matching the engine lift.” An honest divergence: the State-monad sugar would have read a shade cleaner in drainStep, but keeping every top-level block lifted the same way — engine and loader both plain mealy over an explicit step — won out: a codebase where every machine wears the same silhouette reads easier than one where each picks its favourite sugar. ↩init and last on a list: the holdback is init (everything but the last), and the delimiter handler is last. One register buys a one-element look-behind, which is all it takes. ↩LOAD_PROGRAM and TRIGGER are separate frames (a deliberate wire-format choice), a corrupted load never runs: nothing runs until a good trigger, and a well-behaved host re-sends a failed load — overwriting the garbage from address 0 — before it ever triggers. So the loader gets atomic-enough behaviour for free, and the “two LOADs each write from 0” test proves the overwrite works. ↩txReady; and the backstop for anything that slips is the whole-frame CRC plus fire-and-forget — a bad frame is dropped, the host times out and re-runs, byte-reproducibly. Lower the baud (a top-level SNat) if it ever bites. ↩ ↩2Tamal.Wire.Cobs beside streaming Tamal.Loader.Cobs; up here it is pure Tamal.Wire — the frame format as list transforms — beside the streaming loader. You write the frame codec twice: once as a fold over lists, where you can think without back-pressure or cycle timing, and once as a clocked machine that must survive a stalling consumer and a dribbling producer. The property tests weld them, so the gnarly clocked version is never the only place the format is written down. ↩