Files
rebbarb/exi_bba/rx_frame_assembler.py
T
Roflin f69b4d084a Model write-port stalls in the assembler testbench (T5)
eth_wr_ready was held constantly HIGH by every test this file ever had, which
is a serious blind spot: on hardware the SPRAM arbiter stalls that port
constantly, because the GC reads the ring over EXI while frames are written
into it.  Any address bookkeeping that depends on ready timing is invisible
until ready actually goes low.

T5 repeats T2's 300-byte frame with ready asserted 1 cycle in 3 and requires
the descriptor and body offset to be IDENTICAL to the unstalled case.

Result: contention does NOT reproduce the one-byte body offset seen on
hardware.  Under stalls the descriptor is still [0,0,1,48] and all 300 body
bytes land at base+4.  So that hypothesis is eliminated, which was the point
of writing it.

Two harness faults found and fixed while building it, both of which produced
false results rather than no result:

  - rx_irq and rx_overflow are 1-cycle pulses, and rx_overflow fires on the
    very tick that consumes the EOF byte -- inside send_frame.  Polling for it
    afterwards missed it entirely, so a correctly DROPPED frame looked like a
    stall.  Both are now latched in the tick helper.
  - send_frame read rx_ready BEFORE tick() applied the next eth_wr_ready.
    rx_ready is combinational on it, so the loop was a cycle out of phase with
    the accept the DUT actually made: it waited on a byte already taken, and
    reported "rx_ready never asserted" for a frame that had stored correctly.
    Ready is now applied, then read, then clocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 17:18:04 +02:00

568 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""RX frame assembler — sync domain (24 MHz).
Receives raw ethernet frames from W5500SPIMaster and writes them into the SPRAM
ring buffer in MX98730EC format.
Ring buffer layout (SPRAM byte addresses)
------------------------------------------
0x0100–0x0FFF 15 pages × 256 bytes = 3840 bytes
Pages 0x01–0x0F; page 0x00 is reserved.
Page wrap: after 0x0F → 0x01 (skip 0x00).
Frame descriptor (4 bytes at page start)
-----------------------------------------------
Byte 0: LRPS (last received packet status) — 0x00
Byte 1: 0x00
Byte 2: total_length[15:8] (big-endian; includes 4 descriptor bytes)
Byte 3: total_length[7:0]
Bytes 4+: raw ethernet frame
Write sequence
--------------
1. Issue 4 SPRAM writes of 0x00 (placeholder descriptor).
2. For each byte received from W5500, issue one SPRAM write.
3. After EOF: rewrite descriptor bytes 2 and 3 with actual length.
4. Advance RWP, push to rx_wptr FIFO, pulse rx_irq.
"""
from amaranth import *
__all__ = ["RXFrameAssembler"]
_RX_PAGE_FIRST = 0x01
_RX_PAGE_LAST = 0x0F
_PAGES_TOTAL = _RX_PAGE_LAST - _RX_PAGE_FIRST + 1 # 15
# Worst-case pages one frame can occupy: 1518 + 4 descriptor bytes -> 6 pages.
_MAX_FRAME_PAGES = 6
class RXFrameAssembler(Elaboratable):
"""Writes incoming ethernet frames into the SPRAM ring buffer.
W5500 streaming interface (sync domain)
----------------------------------------
rx_data / rx_valid / rx_ready : byte stream
rx_sof / rx_eof : frame delimiters (same cycle as rx_valid)
SPRAM write interface (to SPRAMArbiter, sync domain)
-----------------------------------------------------
eth_wr_addr / eth_wr_data / eth_wr_valid / eth_wr_ready
CDC outputs (wired by BBATop)
-----------------------------
rx_wptr_w_data / rx_wptr_w_en / rx_wptr_w_rdy
rx_irq : 1-cycle pulse → PulseSynchronizer input
rx_enabled : controlled by NCRA SR bit (from BBARegisterFile)
"""
def __init__(self):
# W5500 stream in
self.rx_data = Signal(8)
self.rx_valid = Signal()
self.rx_ready = Signal()
self.rx_sof = Signal()
self.rx_eof = Signal()
# SPRAM write out
self.eth_wr_addr = Signal(16)
self.eth_wr_data = Signal(8)
self.eth_wr_valid = Signal()
self.eth_wr_ready = Signal()
# RWP FIFO write-side (sync→exi)
self.rx_wptr_w_data = Signal(8)
self.rx_wptr_w_en = Signal()
self.rx_wptr_w_rdy = Signal()
# rx_irq pulse (→ PulseSynchronizer)
self.rx_irq = Signal()
# RX gate from NCRA SR bit
self.rx_enabled = Signal()
# ── Ring-overflow protection (RBFI) ───────────────────────────────
# The GC's read pointer, so we can tell how much of the ring is still
# unread. Without it this module overwrote pages the GC had not read
# yet: a traffic burst corrupted whatever frame the driver was midway
# through, with no indication that it had happened.
self.rrp = Signal(8, init=_RX_PAGE_FIRST)
# Pulses when a frame had to be DROPPED because the ring was full.
# Drives IR[7] (RBFI), which the GC's IMR enables (0x86).
self.rx_overflow = Signal()
def elaborate(self, platform):
m = Module()
# ── Ring-buffer state ─────────────────────────────────────────────
rwp = Signal(8, init=_RX_PAGE_FIRST) # current RX write page (1–15)
# Write address within current frame
wr_addr = Signal(16)
# Number of frame data bytes received
data_ctr = Signal(12)
# Total length = data_ctr + 4
total_len = Signal(12)
# Descriptor base (rwp*256) — saved when frame starts
desc_base = Signal(16)
# Placeholder descriptor byte counter (0..3)
desc_ctr = Signal(2)
# Number of pages consumed by this frame (rounded up)
pages_used = Signal(5)
# Default: no pulses
m.d.sync += self.rx_irq.eq(0)
m.d.sync += self.rx_wptr_w_en.eq(0)
m.d.sync += self.rx_overflow.eq(0)
# ── How many pages may we still consume? ──────────────────────────
# rwp == rrp means EMPTY, so writing all 15 pages would wrap back to
# "empty" and lose the lot. Usable = (rrp - rwp - 1) mod 15, which is
# 14 when the ring is empty.
rrp_s = Signal(4)
m.d.comb += rrp_s.eq(Mux((self.rrp >= _RX_PAGE_FIRST)
& (self.rrp <= _RX_PAGE_LAST),
self.rrp, _RX_PAGE_FIRST))
raw = Signal(5)
free = Signal(5)
with m.If(rrp_s >= rwp):
m.d.comb += raw.eq(rrp_s - rwp)
with m.Else():
m.d.comb += raw.eq(rrp_s + _PAGES_TOTAL - rwp)
m.d.comb += free.eq(Mux(raw == 0, _PAGES_TOTAL - 1, raw - 1))
# A frame's length is not known until EOF, so reserve the worst case:
# 1518 bytes + a 4-byte descriptor is 6 pages. Starting a frame with
# less room than that risks running into unread data partway through,
# which is worse than refusing it outright.
# REGISTERED, not combinational. The free-space calculation is a
# mod-15 subtract plus a comparator, and feeding that into the FSM's
# branch condition cost real depth in `sync` -- the domain with the
# least margin in this design (clk fell 27.8-32.2 -> 27.0-28.0 MHz
# when this was comb). One cycle of staleness is harmless: rwp only
# moves at end-of-frame and rrp only when the GC writes it, so both
# are many cycles old by the time the next SOF arrives.
have_room = Signal()
m.d.sync += have_room.eq(free >= _MAX_FRAME_PAGES)
# Combinatorial outputs
m.d.comb += total_len.eq(data_ctr + 4)
with m.FSM(domain="sync", name="rx_fsm"):
with m.State("IDLE"):
m.d.comb += self.rx_ready.eq(0)
m.d.sync += self.eth_wr_valid.eq(0)
with m.If(self.rx_valid & self.rx_sof & self.rx_enabled
& ~have_room):
# Ring full: discard this frame rather than overwrite data
# the GC has not read. This is what the real BBA's RBFI
# reports, and what this module previously lacked.
m.next = "DROP_FRAME"
with m.Elif(self.rx_valid & self.rx_sof & self.rx_enabled):
frame_base = Signal(16)
m.d.comb += frame_base.eq(Cat(Const(0, 8), rwp))
m.d.sync += desc_base.eq(frame_base)
m.d.sync += wr_addr.eq(frame_base)
m.d.sync += data_ctr.eq(0)
m.d.sync += desc_ctr.eq(0)
m.next = "WRITE_PLACEHOLDER"
with m.State("DROP_FRAME"):
# Consume and discard to EOF. rx_ready MUST stay high here:
# stalling the ethernet master instead would back pressure into
# the W5100's own buffer and turn a dropped frame into a wedged
# receiver.
m.d.comb += self.rx_ready.eq(1)
with m.If(self.rx_valid & self.rx_eof):
m.d.sync += self.rx_overflow.eq(1)
m.next = "IDLE"
with m.State("WRITE_PLACEHOLDER"):
# Write 4 bytes of 0x00 as placeholder descriptor.
#
# ⚠️ Advance on `valid & ready`, NOT on `ready` alone.
# `eth_wr_valid` is scheduled with m.d.sync and so is LOW for
# the whole first cycle in this state, while `ready` is tested
# immediately -- and `ready` is usually already high here.
# Testing `ready` alone therefore counted a write that was
# never issued: only THREE placeholder bytes went out and
# wr_addr ended at desc_base+3, so the frame body started at
# descriptor byte 3 and EVERY RECEIVED FRAME WAS SHIFTED ONE
# BYTE EARLY.
#
# Hardware symptom (2026-10-09): the ring filled normally and
# RWP advanced, but the GC's ethertype test read the wrong
# offset and silently dropped every frame -- "no ACK" from the
# host with no other sign of trouble. The matching
# descriptor bug (same cause, in WRITE_LEN_HI) showed up as a
# descriptor of 00 00 00 01.
#
# The first byte is written twice as a consequence of
# eth_wr_addr trailing wr_addr by a cycle, and desc_base+3 is
# left unwritten -- both harmless, because every descriptor
# byte is either zero anyway or overwritten by WRITE_LEN_*.
# What matters is that wr_addr ends at desc_base+4.
m.d.sync += self.eth_wr_addr .eq(wr_addr)
m.d.sync += self.eth_wr_data .eq(0x00)
m.d.sync += self.eth_wr_valid.eq(1)
with m.If(self.eth_wr_valid & self.eth_wr_ready):
m.d.sync += wr_addr.eq(wr_addr + 1)
with m.If(desc_ctr == 3):
m.d.sync += self.eth_wr_valid.eq(0)
m.next = "RECV_AND_WRITE"
with m.Else():
m.d.sync += desc_ctr.eq(desc_ctr + 1)
with m.State("RECV_AND_WRITE"):
# Accept bytes from W5500 and write each to SPRAM immediately
m.d.comb += self.rx_ready.eq(~self.eth_wr_valid | self.eth_wr_ready)
with m.If(self.rx_valid & (~self.eth_wr_valid | self.eth_wr_ready)):
m.d.sync += self.eth_wr_addr.eq(wr_addr)
m.d.sync += self.eth_wr_data.eq(self.rx_data)
m.d.sync += self.eth_wr_valid.eq(1)
m.d.sync += wr_addr.eq(wr_addr + 1)
m.d.sync += data_ctr.eq(data_ctr + 1)
with m.If(self.rx_eof):
m.next = "WAIT_LAST_WRITE"
with m.Elif(self.eth_wr_valid & self.eth_wr_ready):
m.d.sync += self.eth_wr_valid.eq(0)
with m.State("WAIT_LAST_WRITE"):
# Wait for the last data byte write to be accepted
with m.If(~self.eth_wr_valid | self.eth_wr_ready):
m.d.sync += self.eth_wr_valid.eq(0)
# Compute pages used: ceil((data_ctr + 4) / 256)
# = (total_len + 255) >> 8 = total_len[11:8] + (total_len[7:0] != 0)
m.d.sync += pages_used.eq(total_len[8:12] + (total_len[:8] != 0))
m.next = "WRITE_LEN_HI"
# ⚠️ Both length writes must advance on `valid & ready`, NOT on
# `ready` alone. `valid` is scheduled with m.d.sync and so is low
# for the whole first cycle in the state, while `ready` is tested
# immediately -- and coming out of WAIT_LAST_WRITE `ready` is
# usually already high. Testing `ready` alone therefore fired both
# valid.eq(1) and valid.eq(0) in the same cycle, the later won, the
# write was never issued, and the length byte was SILENTLY DROPPED.
#
# Hardware symptom (2026-10-09): a descriptor of 00 00 00 01 --
# byte 2 still at its placeholder 0x00 because the HI write was
# dropped -- so the GC read a 1-byte frame length and rejected the
# ring. Intermittent by nature: it depends on whether the arbiter
# happens to hold `ready` high on the entry cycle, which is why
# bring-up's 342-byte DHCP frame read a correct 00 00 01 5A and
# this went unnoticed.
with m.State("WRITE_LEN_HI"):
# Overwrite descriptor byte 2 with total_len[15:8]
m.d.sync += self.eth_wr_addr.eq(desc_base + 2)
m.d.sync += self.eth_wr_data.eq(total_len[8:12])
m.d.sync += self.eth_wr_valid.eq(1)
with m.If(self.eth_wr_valid & self.eth_wr_ready):
m.d.sync += self.eth_wr_valid.eq(0)
m.next = "WRITE_LEN_LO"
with m.State("WRITE_LEN_LO"):
# Overwrite descriptor byte 3 with total_len[7:0]
m.d.sync += self.eth_wr_addr.eq(desc_base + 3)
m.d.sync += self.eth_wr_data.eq(total_len[:8])
m.d.sync += self.eth_wr_valid.eq(1)
with m.If(self.eth_wr_valid & self.eth_wr_ready):
m.d.sync += self.eth_wr_valid.eq(0)
m.next = "ADVANCE_RWP"
with m.State("ADVANCE_RWP"):
# next_rwp = ((rwp - 1 + pages_used) % 15) + 1
next_rwp_raw = Signal(8)
m.d.comb += next_rwp_raw.eq(rwp + pages_used)
with m.If(next_rwp_raw > _RX_PAGE_LAST):
m.d.sync += rwp.eq(next_rwp_raw - _PAGES_TOTAL)
with m.Else():
m.d.sync += rwp.eq(next_rwp_raw)
m.next = "PUSH_WPT"
with m.State("PUSH_WPT"):
with m.If(self.rx_wptr_w_rdy):
m.d.sync += self.rx_wptr_w_data.eq(rwp)
m.d.sync += self.rx_wptr_w_en.eq(1)
m.d.sync += self.rx_irq.eq(1)
m.next = "IDLE"
return m
# ── Testbench ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
from amaranth.sim import Simulator, Period
dut = RXFrameAssembler()
errors = []
def brief(v):
"""Long byte lists are unreadable in a pass/fail line; show the ends
and the length, and keep the full value only for a failure."""
if isinstance(v, list) and len(v) > 8:
head = ", ".join(str(x) for x in v[:4])
tail = ", ".join(str(x) for x in v[-2:])
return f"[{head}, ... {tail}] ({len(v)} bytes)"
return str(v)
def check(name, got, want):
ok = got == want
print(f" {'ok ' if ok else 'FAIL'} {name}: "
f"got {brief(got)}, want {brief(want)}")
if not ok:
if isinstance(got, list) and isinstance(want, list):
first = next((i for i, (g, w) in enumerate(zip(got, want))
if g != w), None)
if first is not None:
print(f" first difference at index {first}: "
f"got {got[first]}, want {want[first]}")
errors.append(name)
async def testbench(ctx):
# Final contents of every SPRAM address the DUT wrote.
#
# The old testbench declared a writes list and never filled it, and
# drove the RX stream OPEN-LOOP -- one byte per tick, never checking
# rx_ready. Both of the 2026-10-09 handshake bugs were therefore
# invisible to it: it only ever asserted on rwp and rx_irq, so a
# dropped descriptor byte or a frame body written one byte early
# changed nothing it looked at.
mem = {}
order = []
# rx_irq and rx_overflow are 1-CYCLE pulses, and rx_overflow fires on
# the very tick that consumes the EOF byte -- i.e. inside send_frame.
# Polling for it afterwards misses it entirely, which first showed up
# as a dropped frame looking like a stall. Latch both in tick().
pulse = {"irq": None, "ovf": False}
# eth_wr_ready stall pattern. Held constantly HIGH by every test this
# file ever had, which is a serious blind spot: on hardware the SPRAM
# arbiter stalls the write port constantly, because the GC is reading
# the ring over EXI while frames are being written into it. Any
# address bookkeeping that depends on ready timing is invisible until
# ready actually goes low. stall[0] = assert ready 1 cycle in N.
stall = [1]
cyc = [0]
def apply_ready():
"""Apply eth_wr_ready for the cycle about to be clocked. It must
be applied BEFORE rx_ready is read: rx_ready is combinational on
it, so reading first and applying second puts the harness a cycle
out of phase with the accept the DUT actually makes."""
cyc[0] += 1
ctx.set(dut.eth_wr_ready, 1 if cyc[0] % stall[0] == 0 else 0)
async def tick_only(n=1):
for _ in range(n):
# A write is issued on a cycle where valid AND ready are both
# high, so sample before the edge that completes it.
if ctx.get(dut.eth_wr_valid) and ctx.get(dut.eth_wr_ready):
a, d = ctx.get(dut.eth_wr_addr), ctx.get(dut.eth_wr_data)
mem[a] = d
order.append((a, d))
await ctx.tick("sync")
if ctx.get(dut.rx_overflow):
pulse["ovf"] = True
if ctx.get(dut.rx_irq):
pulse["irq"] = ctx.get(dut.rx_wptr_w_data)
async def tick(n=1):
for _ in range(n):
apply_ready()
await tick_only()
tag = ["?"]
async def send_frame(frame):
pulse["irq"] = None
pulse["ovf"] = False
"""Push a frame RESPECTING BACK-PRESSURE, the way the real
ethernet master does. rx_ready is LOW through IDLE and the whole
placeholder-descriptor write, so a master that advances anyway
simply loses those bytes -- which is what the old testbench did,
and why it could not see the frame body land at the wrong
offset."""
for i, b in enumerate(frame):
ctx.set(dut.rx_data, b)
ctx.set(dut.rx_valid, 1)
ctx.set(dut.rx_sof, 1 if i == 0 else 0)
ctx.set(dut.rx_eof, 1 if i == len(frame) - 1 else 0)
# Apply ready, THEN read rx_ready, THEN clock -- so the
# value the DUT decides on is the one this loop checked.
# Polling before applying had the loop waiting on a byte the
# DUT had already accepted: the frame stored correctly while
# send_frame reported rx_ready never asserted.
guard = 0
while True:
apply_ready()
if ctx.get(dut.rx_ready):
break
await tick_only()
guard += 1
if guard > 5000:
errors.append(f"send_frame[{tag[0]}] byte {i}: "
f"rx_ready never asserted")
return
await tick_only()
ctx.set(dut.rx_valid, 0)
ctx.set(dut.rx_sof, 0)
ctx.set(dut.rx_eof, 0)
async def wait_irq(limit=200):
for _ in range(limit):
if pulse["irq"] is not None:
return pulse["irq"]
await tick()
return None
async def wait_done(limit=400):
"""Wait for a frame to be either stored (rx_irq) or discarded
(rx_overflow), reading the latches so a pulse that fired during
send_frame is not lost."""
for _ in range(limit):
if pulse["ovf"]:
return "dropped"
if pulse["irq"] is not None:
return "stored"
await tick()
return "timeout"
def descriptor(page):
base = page << 8
return [mem.get(base + i) for i in range(4)]
def body(page, n):
base = (page << 8) + 4
return [mem.get(base + i) for i in range(n)]
ctx.set(dut.rx_wptr_w_rdy, 1)
ctx.set(dut.rx_enabled, 1)
await tick(2)
# ── T1: short frame at page 1 ────────────────────────────────────
f1 = [0xAA, 0xBB, 0xCC, 0xDD, 0x08, 0x00, 0x45, 0x00, 0x00, 0x01]
tag[0] = "T1"
await send_frame(f1)
check("T1 rwp advance", await wait_irq(), 2)
total1 = len(f1) + 4
check("T1 descriptor", descriptor(1),
[0x00, 0x00, (total1 >> 8) & 0xFF, total1 & 0xFF])
# The body MUST start at base+4. This is the check that catches a
# placeholder write being dropped: with only three placeholder bytes
# written the body landed at base+3 and every frame was shifted one
# byte early, which on hardware made the GC drop every frame.
check("T1 body at base+4", body(1, len(f1)), f1)
await tick(4)
# ── T2: frame LONGER THAN 255 BYTES, so the descriptor's HIGH length
# byte is non-zero. This is the case that catches a dropped
# WRITE_LEN_HI: with the high byte left at its placeholder 0x00 the GC
# read an impossible length (hardware saw 00 00 00 01) and rejected
# the ring. Every earlier test used a short frame, where a dropped
# high byte is indistinguishable from a correct zero. ───────────────
f2 = [(i * 7 + 3) & 0xFF for i in range(300)]
tag[0] = "T2"
await send_frame(f2)
rwp2 = await wait_irq()
total2 = len(f2) + 4
check("T2 descriptor (len > 255)", descriptor(2),
[0x00, 0x00, (total2 >> 8) & 0xFF, total2 & 0xFF])
check("T2 body at base+4", body(2, len(f2)), f2)
# 304 bytes spans two pages, so page 2 -> page 4.
check("T2 rwp advance (2 pages)", rwp2, 4)
await tick(4)
# ── T4: ring overflow must DROP, not overwrite ───────────────────
# rrp is held still, as if the GC had stopped reading. The writer
# must refuse new frames once the ring is nearly full and leave the
# unread ones intact. Before this existed the writer simply lapped
# the reader, so a traffic burst corrupted whatever frame the driver
# was midway through -- with no indication it had happened. That is
# what produced a "descriptor" of 00 00 00 01 on hardware: frame data
# from the frame that had replaced the one being read.
ctx.set(dut.rrp, 4) # GC has consumed up to page 4
await tick(2)
f3 = [(i * 3 + 1) & 0xFF for i in range(300)] # 2 pages each
tag[0] = "T4-first"
await send_frame(f3)
check("T4 first frame stored", await wait_done(), "stored")
page4 = descriptor(4) + body(4, 32)
outcomes = []
for _ in range(12):
tag[0] = "T4-fill"
await send_frame(f3)
outcomes.append(await wait_done())
if outcomes[-1] == "dropped":
break
check("T4 ring eventually refuses a frame",
outcomes[-1] if outcomes else "none", "dropped")
# THE property: the frame the GC has not read yet is untouched.
check("T4 unread page preserved", descriptor(4) + body(4, 32), page4)
# Once the GC catches up, frames must be accepted again -- a full ring
# must not latch the receiver off. The writer is at page 14 here, so
# rrp must move well past it to free the 6 pages a worst-case frame
# needs: free = (rrp - rwp - 1) mod 15, so rrp = 8 gives 8 free.
ctx.set(dut.rrp, 8)
await tick(4)
tag[0] = "T4-again"
await send_frame(f3)
check("T4 accepts again after RRP advances", await wait_done(), "stored")
await tick(4)
# ── T5: the same frame WITH THE WRITE PORT STALLING ──────────────
# Models arbiter contention: ready asserted only 1 cycle in 3. The
# descriptor and body offset must be identical to the unstalled case.
# This is the case hardware runs in and no previous test ever did --
# hardware reported a frame whose body was one byte late while
# simulation with ready held high was byte-perfect.
ctx.set(dut.rrp, 1)
await tick(2)
stall[0] = 3
f5 = [(i * 5 + 9) & 0xFF for i in range(300)]
tag[0] = "T5"
await send_frame(f5)
rwp5 = await wait_done()
check("T5 stored despite stalls", rwp5, "stored")
pg5 = (ctx.get(dut.rx_wptr_w_data) - 2) & 0xFF # page it was written to
total5 = len(f5) + 4
check("T5 descriptor under stall", descriptor(pg5),
[0x00, 0x00, (total5 >> 8) & 0xFF, total5 & 0xFF])
check("T5 body at base+4 under stall", body(pg5, len(f5)), f5)
stall[0] = 1
await tick(4)
# ── T3: RX disabled — SOF must be ignored ────────────────────────
before = len(mem)
ctx.set(dut.rx_enabled, 0)
ctx.set(dut.rx_data, 0xDE)
ctx.set(dut.rx_valid, 1)
ctx.set(dut.rx_sof, 1)
await tick(8)
ctx.set(dut.rx_valid, 0)
ctx.set(dut.rx_sof, 0)
check("T3 no writes while rx disabled", len(mem), before)
sim = Simulator(dut)
sim.add_clock(Period(MHz=24), domain="sync")
sim.add_testbench(testbench)
with sim.write_vcd("RXFrameAssembler.vcd"):
sim.run()
if errors:
print("\nFAILURES:")
for e in errors:
print(" -", e)
sys.exit(1)
print("\nAll tests passed.")