Files
rebbarb/exi_bba/rx_frame_assembler.py
T
Roflin 20d64ac416 rx_frame_assembler: document the ring and descriptor, drop stale references
844 -> 818 lines, 30% -> 12% comment-only.  The descriptor bit layout is kept
in FULL, because it is the one interface in this module an external driver
parses; what went was the disassembly listing that proves it (CLAUDE.md holds
that) and the per-bug narrative around each fix.

Stale references fixed:

  * The module claimed to receive frames "from W5500SPIMaster".  The W5100
    parallel master is the default back-end; the stream interface is
    deliberately identical, so the text now names neither.
  * "RWP FIFO write-side (sync->exi)" and "rx_irq : 1-cycle pulse ->
    PulseSynchronizer input" both described the old split clock domains.  The
    exi domain was merged into sync; that FIFO is a SyncFIFO and the pulse is
    a plain wire.
  * The module docstring's write sequence said descriptor "bytes 0, 1 and 2"
    are rewritten and "byte 3 (status) stays 0x00", and WRITE_D2's comment
    said byte 3 "keeps the placeholder".  Both predate WRITE_D3, which exists
    precisely because the placeholder NEVER reaches desc_base+3 -- so the
    file contradicted its own next state, and in the direction that sounds
    reassuring.

Two comment blocks were duplicated outright: the registered-ring-bounds
timing note appeared twice in a row with slightly different wording, and the
NCRA-reset rationale appeared both on the port and again at the logic.

Structure: elaborate() is now _ring_bounds, _free_space, _frame_fsm and
_ncra_reset, with _ncra_reset still elaborated last so it keeps priority over
ADVANCE_RWP.  The FSM's docstring lists all eleven states in visit order, and
the valid-&-ready rule that both 2026-10-09 handshake bugs violated is stated
once for every write state instead of twice at two of them.

Also fixed a real (if harmless) bug in the testbench: send_frame's docstring
sat after two statements, making it a no-op expression rather than a
docstring.

VERIFIED EQUIVALENT: generated RTLIL identical, 1930 lines, after stripping
\src attributes.  Full module suite passes, bba_top T9 6/6 at EXI_HALF=1, 2
and 8, and this module's own 25 checks all pass including T5's stalled-write
case and T7's small-ring wrap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:02:34 +02:00

819 lines
36 KiB
Python

"""RX frame assembler — takes received ethernet frames into the SPRAM ring.
Runs entirely in the `sync` domain (24 MHz). Accepts a byte stream from
whichever ethernet master is built (`W5100ParallelMaster` by default, or
`W5500SPIMaster`) and lays each frame out in the ring the way the GameCube's
own BBA driver expects to find it.
Ring layout
-----------
SPRAM bytes 0x0100-0x0FFF, as 15 pages of 256 bytes. Pages 0x01-0x0F are
usable and page 0x00 is reserved for TX, so the wrap is 0x0F → 0x01. The GC
can narrow the ring through its BP/RHBP registers; see `_ring_bounds`.
A frame occupies consecutive pages from its own page start, and the next frame
begins on the next free page -- frames are never split across the wrap, which
is why the writer reserves a worst-case 6 pages before starting one.
Frame descriptor
----------------
Four bytes at each page start, forming a PACKED 32-BIT LITTLE-ENDIAN WORD.
This is not a byte-wise layout, and it must not be "simplified" into a plain
big-endian length:
bits 0-11 next_packet_ptr the PAGE the NEXT frame starts at
bits 12-23 packet_len total bytes, INCLUDING these 4
bits 24-31 status 0x00 is good; 0x80 and 0x04 are errors
which puts on the wire:
byte 0 next_packet_ptr[7:0]
byte 1 packet_len[3:0] << 4 | next_packet_ptr[11:8]
byte 2 packet_len[11:4]
byte 3 status
byte 4+ the raw ethernet frame
The driver takes the low 12 bits of that word and writes them straight to its
RRP register, so `next_packet_ptr` is load-bearing and not informational. The
layout was read out of libogc's own compiled driver rather than inferred; the
disassembly is recorded in CLAUDE.md.
Write sequence
--------------
1. Write four placeholder 0x00 bytes where the descriptor will go.
2. Write each received byte straight through, one SPRAM write per byte.
3. On EOF, compute the next page, then write descriptor bytes 0-3 for real.
4. Advance RWP, push it to the register file, and pulse `rx_irq`.
The descriptor is written last because neither the length nor the next page is
known until the frame has ended.
"""
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.
Ethernet byte stream in
-----------------------
rx_data, rx_valid, rx_ready
Standard ready/valid handshake. `rx_ready` is LOW while the
descriptor placeholder is being written, so the master must honour it.
rx_sof, rx_eof
Frame delimiters, asserted in the same cycle as `rx_valid`.
SPRAM write port out (to `SPRAMArbiter`)
----------------------------------------
eth_wr_addr, eth_wr_data, eth_wr_valid, eth_wr_ready
To the register file
-------------------
rx_wptr_w_data, rx_wptr_w_en, rx_wptr_w_rdy
The new write page after each stored frame, through a `SyncFIFO`.
rx_irq
One-cycle pulse: a frame is in the ring. Becomes IR[1] (RI).
rx_overflow
One-cycle pulse: a frame was DROPPED because the ring was full.
Becomes IR[7] (RBFI).
From the register file
---------------------
rx_enabled
NCRA's SR bit. Frames are ignored entirely while it is low.
rrp
The GC's read pointer, so free ring space can be computed.
bp, rhbp
Ring geometry the GC asked for; see `_ring_bounds`.
ncra_rst
The GC's NCRA reset; see `_ncra_reset`.
geom_honoured
High while `bp`/`rhbp` are usable and in force, low while they have
been rejected and the default layout is standing in. Nothing consumes
it in the design -- it exists so the tests can tell the two cases
apart, which is otherwise unobservable from outside.
"""
def __init__(self):
# Ethernet 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 hand-off to the register file
self.rx_wptr_w_data = Signal(8)
self.rx_wptr_w_en = Signal()
self.rx_wptr_w_rdy = Signal()
self.rx_irq = Signal()
self.rx_overflow = Signal()
self.rx_enabled = Signal()
self.rrp = Signal(8, init=_RX_PAGE_FIRST)
self.bp = Signal(12, init=_RX_PAGE_FIRST) # first RX page
self.rhbp = Signal(12, init=_RX_PAGE_LAST) # last RX page
self.geom_honoured = Signal()
self.ncra_rst = Signal()
def elaborate(self, platform):
m = Module()
# The current RX write page, shared by the free-space calculation, the
# frame FSM and the NCRA reset.
rwp = Signal(8, init=_RX_PAGE_FIRST)
# All three outputs are one-cycle pulses, so they default low and the
# FSM below overrides them for the cycle it needs.
m.d.sync += [self.rx_irq.eq(0),
self.rx_wptr_w_en.eq(0),
self.rx_overflow.eq(0)]
pg = self._ring_bounds(m)
have_room = self._free_space(m, rwp, pg)
self._frame_fsm(m, rwp, pg, have_room)
self._ncra_reset(m, rwp, pg)
return m
def _ring_bounds(self, m):
"""Sanitise the GC's requested ring geometry.
Returns a `(first, last, total)` tuple of signals describing the ring
actually in use.
`bp`/`rhbp` are honoured only when they describe a usable ring inside
SPRAM; otherwise the default 1-15 layout stands and `geom_honoured`
goes low so the disagreement is observable. Page 0 holds the TX
buffer, so BP must be at least 1, and a garbage write (BP = 0, or RHBP
below BP) would otherwise give a zero or negative ring size and send
the writer outside it.
⚠️ These are REGISTERED and 4 BITS WIDE, both deliberately, and both
for timing rather than function.
Computing them combinationally put bp/rhbp → comparators → Mux →
subtract → total → add → compare → free → compare → have_room in ONE
path, where the constants it replaced had been folded away. `clk`
fmax collapsed from ~29 MHz to ~17 MHz and ALL EIGHT SEEDS FAILED the
24 MHz target. Registering split the path, but 8-bit variable-vs-
variable comparators are carry chains where variable-vs-constant folds
into a couple of LUTs, so it was still only ~20 MHz.
Ring pages are 1-15, so 4 bits is the natural width, and the
`geom_ok` test guarantees bp < rhbp <= 0x0F -- which is what makes the
narrow slice safe. `bp`/`rhbp` only change when the GC writes BP or
RHBP, so a cycle of staleness costs nothing.
"""
geom_ok = Signal()
geom_ok_c = Signal()
pg_first = Signal(4, init=_RX_PAGE_FIRST)
pg_last = Signal(4, init=_RX_PAGE_LAST)
pg_total = Signal(5, init=_PAGES_TOTAL)
m.d.comb += geom_ok_c.eq((self.bp >= _RX_PAGE_FIRST)
& (self.rhbp <= _RX_PAGE_LAST)
& (self.rhbp > self.bp))
m.d.sync += [
geom_ok .eq(geom_ok_c),
pg_first.eq(Mux(geom_ok_c, self.bp[0:4], _RX_PAGE_FIRST)),
pg_last .eq(Mux(geom_ok_c, self.rhbp[0:4], _RX_PAGE_LAST)),
pg_total.eq(Mux(geom_ok_c, self.rhbp[0:4] - self.bp[0:4] + 1,
_PAGES_TOTAL)),
]
m.d.comb += self.geom_honoured.eq(geom_ok)
return pg_first, pg_last, pg_total
def _free_space(self, m, rwp, pg):
"""How many pages may still be consumed. Returns `have_room`.
`rwp == rrp` means EMPTY, so writing the whole ring would wrap back to
looking empty and lose the lot. Usable is therefore
`(rrp - rwp - 1) mod total`, which is `total - 1` on an empty ring.
A frame's length is unknown until EOF, so starting one reserves the
WORST case -- 1518 bytes plus the descriptor, six pages. Beginning a
frame with less room than that risks running into unread data partway
through, which is worse than refusing it outright.
⚠️ `have_room` and the clamped `rrp` are REGISTERED, for timing. The
free-space sum is a mod-15 subtract plus a comparator, and feeding
that straight 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). A 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.
"""
pg_first, pg_last, pg_total = pg
rrp_s = Signal(4, init=_RX_PAGE_FIRST)
m.d.sync += rrp_s.eq(Mux((self.rrp >= pg_first)
& (self.rrp <= pg_last),
self.rrp[0:4], pg_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 + pg_total - rwp)
m.d.comb += free.eq(Mux(raw == 0, pg_total - 1, raw - 1))
have_room = Signal()
m.d.sync += have_room.eq(free >= _MAX_FRAME_PAGES)
return have_room
def _frame_fsm(self, m, rwp, pg, have_room):
"""Store one frame per pass through the ring.
States, in the order a frame visits them:
IDLE wait for SOF; branch on whether the ring has
room for a worst-case frame
DROP_FRAME ring full: consume to EOF and raise rx_overflow
WRITE_PLACEHOLDER four 0x00 bytes where the descriptor will go,
so the body starts at page+4
RECV_AND_WRITE stream each received byte into SPRAM
WAIT_LAST_WRITE let the final body write be accepted, and work
out how many pages the frame used
CALC_NEXT compute the page the NEXT frame will start at
WRITE_D0..D3 write the real descriptor, now that length and
next page are both known
ADVANCE_RWP move the write pointer to that next page
PUSH_WPT hand RWP to the register file and pulse rx_irq
⚠️ EVERY write state advances on `valid & ready`, never on `ready`
alone. `eth_wr_valid` is scheduled with `m.d.sync` and so is LOW for
the whole first cycle in a state, while `ready` is tested immediately
-- and `ready` is usually already high on entry. Testing `ready`
alone fires `valid.eq(1)` and `valid.eq(0)` in the same cycle, the
later assignment wins, and the write is never issued.
That bug existed twice, and neither instance was visible to a
testbench that held `ready` high:
* in the placeholder, only THREE bytes went out, so the body began
at page+3 and EVERY FRAME WAS SHIFTED ONE BYTE EARLY. On hardware
the ring filled and RWP advanced normally while the GC's
ethertype test read the wrong offset and silently dropped
everything.
* in the descriptor, the length write was dropped, leaving byte 2 at
its placeholder -- hardware read a descriptor of 00 00 00 01 and
rejected the ring. Intermittent, because it depended on whether
the arbiter happened to hold `ready` high on the entry cycle.
"""
pg_first, pg_last, pg_total = pg
wr_addr = Signal(16) # next SPRAM byte address to write
data_ctr = Signal(12) # frame data bytes received so far
total_len = Signal(12) # data_ctr + the 4 descriptor bytes
next_page = Signal(12) # page the NEXT frame will start at
desc_base = Signal(16) # this frame's page start (rwp * 256)
desc_ctr = Signal(2) # placeholder bytes written, 0..3
pages_used = Signal(5)
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):
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"):
# Discard to EOF rather than overwrite data the GC has not
# read. This is what the real BBA reports through RBFI.
#
# rx_ready MUST stay high: 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"):
# The first byte is written twice, because eth_wr_addr trails
# wr_addr by a cycle, and desc_base+3 is never reached. Both
# are harmless -- every descriptor byte is overwritten by
# WRITE_D0..D3 later. What matters is that wr_addr ends at
# desc_base+4, so the frame body starts there.
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"):
# One SPRAM write per received byte, with no intermediate
# buffering: accept a byte whenever the write port can take
# the previous one.
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"):
with m.If(~self.eth_wr_valid | self.eth_wr_ready):
m.d.sync += self.eth_wr_valid.eq(0)
# pages = ceil(total_len / 256)
m.d.sync += pages_used.eq(total_len[8:12] + (total_len[:8] != 0))
m.next = "CALC_NEXT"
with m.State("CALC_NEXT"):
# The descriptor carries next_packet_ptr, so the next page has
# to be known BEFORE the descriptor is written.
next_rwp_raw = Signal(8)
m.d.comb += next_rwp_raw.eq(rwp + pages_used)
with m.If(next_rwp_raw > pg_last):
m.d.sync += next_page.eq(next_rwp_raw - pg_total)
with m.Else():
m.d.sync += next_page.eq(next_rwp_raw)
m.next = "WRITE_D0"
with m.State("WRITE_D0"):
# next_packet_ptr[7:0]
m.d.sync += self.eth_wr_addr.eq(desc_base + 0)
m.d.sync += self.eth_wr_data.eq(next_page[0: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 = "WRITE_D1"
with m.State("WRITE_D1"):
# packet_len[3:0] << 4 | next_packet_ptr[11:8].
# Cat puts its FIRST argument in the low bits.
m.d.sync += self.eth_wr_addr.eq(desc_base + 1)
m.d.sync += self.eth_wr_data.eq(
Cat(next_page[8:12], total_len[0:4]))
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_D2"
with m.State("WRITE_D2"):
# packet_len[11:4]
m.d.sync += self.eth_wr_addr.eq(desc_base + 2)
m.d.sync += self.eth_wr_data.eq(total_len[4: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_D3"
with m.State("WRITE_D3"):
# status = 0x00, "no error": the driver tests this byte
# against 0x84.
#
# ⚠️ This MUST be written explicitly and cannot lean on the
# placeholder, because WRITE_PLACEHOLDER never reaches
# desc_base+3 (see its note). Leaving it would hand the
# driver whatever the previous frame -- or power-on -- left in
# that SPRAM cell, and a stale 0x80 or 0x04 discards the frame
# as an error. Simulation reports an unwritten cell; real
# SPRAM has no such tell.
m.d.sync += self.eth_wr_addr.eq(desc_base + 3)
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 += self.eth_wr_valid.eq(0)
m.next = "ADVANCE_RWP"
with m.State("ADVANCE_RWP"):
m.d.sync += rwp.eq(next_page)
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"
def _ncra_reset(self, m, rwp, pg):
"""Return the write pointer to the ring start on the GC's NCRA reset.
Must be elaborated AFTER the FSM so that it takes priority over
ADVANCE_RWP in the same cycle.
This has to happen here as well as in the register file: the two are
copies of the same pointer, and resetting only one leaves them
disagreeing about where the ring is. A driver that then syncs RRP to
the register file's value points somewhere the writer is not, the
free-space sum shrinks with every frame, and once it falls below
_MAX_FRAME_PAGES EVERY frame is dropped -- silently, since a dropped
frame never reaches the ring to be counted. The hardware symptom was
"0 seen, 0 bad desc, 0 lapped".
No in-flight frame needs abandoning: the same NCRA write that pulses
this also clears the SR bit, so `rx_enabled` is low and the FSM is
already in IDLE.
"""
pg_first, _pg_last, _pg_total = pg
with m.If(self.ncra_rst):
m.d.sync += rwp.eq(pg_first)
# ── 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, plus the order
# the writes were issued in.
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 showed up as a
# dropped frame looking like a stall, so tick() latches both.
pulse = {"irq": None, "ovf": False}
# eth_wr_ready stall pattern: assert ready 1 cycle in stall[0].
# Holding it constantly high is a serious blind spot, because on
# hardware the SPRAM arbiter stalls this port constantly -- the GC
# reads 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 = [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):
"""Push a frame RESPECTING BACK-PRESSURE, the way the real
ethernet master does. rx_ready is LOW through IDLE and the whole
placeholder write, so a master that advances anyway simply loses
those bytes -- which is how a frame body landing at the wrong
offset went unnoticed."""
pulse["irq"] = None
pulse["ovf"] = False
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 left the loop waiting on a byte the DUT had
# already accepted.
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 pack_desc(total, next_ptr, status=0):
"""The 4 descriptor bytes the GC's driver expects."""
word = (status << 24) | ((total & 0xFFF) << 12) | (next_ptr & 0xFFF)
return [(word >> (8 * i)) & 0xFF for i in range(4)]
def libogc_decode(d):
"""Decode exactly as libogc's bba_interrupt does, so these tests
check the driver's ALGORITHM rather than our own packing:
lwbrx -> little-endian word from the 4 bytes
rlwinm r4,r9,20,20,31 -> packet_len = (word >> 12) & 0xFFF
srwi r9,r9,24 -> status = word >> 24
word & 0xFFF -> next_packet_ptr
Returns (payload_len, next_ptr, status_is_error)."""
if any(b is None for b in d):
return (None, None, None)
word = d[0] | (d[1] << 8) | (d[2] << 16) | (d[3] << 24)
return (((word >> 12) & 0xFFF) - 4, word & 0xFFF,
bool((word >> 24) & 0x84))
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), pack_desc(total1, 2))
check("T1 descriptor via libogc decode", libogc_decode(descriptor(1)),
(len(f1), 2, False))
# The body MUST start at base+4. This is the check that catches a
# dropped placeholder write: with only three written, the body landed
# at base+3 and the GC dropped 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
# nibble is non-zero. A short frame cannot distinguish a dropped
# high-byte write 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), pack_desc(total2, 4))
check("T2 descriptor via libogc decode", libogc_decode(descriptor(2)),
(len(f2), 4, False))
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; lapping the reader corrupts whatever frame the
# driver is midway through, with nothing reporting it.
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 offsets must match the unstalled case exactly.
# This is the condition hardware runs in, and holding ready high hid a
# frame body landing one byte late.
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
next5 = ctx.get(dut.rx_wptr_w_data)
check("T5 descriptor under stall", descriptor(pg5),
pack_desc(total5, next5))
check("T5 descriptor via libogc decode", libogc_decode(descriptor(pg5)),
(len(f5), next5, False))
check("T5 body at base+4 under stall", body(pg5, len(f5)), f5)
stall[0] = 1
await tick(4)
# ── T6: NCRA reset must return the WRITE pointer to page 1 ───────
# If this module keeps its own RWP while the register file resets its
# copy, the two disagree, the free-space sum shrinks with every frame,
# and eventually every frame is dropped silently.
ctx.set(dut.rrp, 1)
await tick(2)
ctx.set(dut.ncra_rst, 1)
await tick(1)
ctx.set(dut.ncra_rst, 0)
await tick(2)
f6 = [0xC0 | (i & 0x0F) for i in range(40)]
await send_frame(f6)
check("T6 rwp back to page 1 after NCRA reset", await wait_irq(), 2)
total6 = len(f6) + 4
check("T6 frame lands at page 1", descriptor(1), pack_desc(total6, 2))
check("T6 descriptor via libogc decode", libogc_decode(descriptor(1)),
(len(f6), 2, False))
check("T6 body at page 1 base+4", body(1, len(f6)), f6)
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)
# ── T7: the GC's BP/RHBP are HONOURED ────────────────────────────
# A driver configuring a SMALLER ring must not be ignored, or we keep
# writing pages it will never read. libogc2 happens to write
# BP=1/RHBP=15, so only a different geometry exercises this.
ctx.set(dut.rx_enabled, 1)
ctx.set(dut.rrp, 1)
ctx.set(dut.bp, 1)
ctx.set(dut.rhbp, 8) # an 8-page ring: pages 1..8
await tick(4)
check("T7 small ring honoured", ctx.get(dut.geom_honoured), 1)
# ⚠️ The ring must be LARGER than _MAX_FRAME_PAGES or nothing is ever
# stored: `free` can never reach the worst-case reservation, so RBFI
# drops every frame. A 4-page ring was tried here first and stored
# NOTHING -- the test passed vacuously. 8 pages is the smallest that
# both exercises the wrap and leaves room.
pages_seen = set()
for n in range(10):
mem.clear()
# Keep the reader moving so the ring never fills.
ctx.set(dut.rrp, (n % 8) + 1)
await tick(4)
await send_frame([0x90 + n] * 32)
if await wait_done() == "stored":
pages_seen |= {a >> 8 for a in mem}
await tick(4)
print(f" T7 pages written with RHBP=8: {sorted(pages_seen)}")
check("T7 stored at least one frame in the small ring",
len(pages_seen) > 0, True)
outside = {p for p in pages_seen if p < 1 or p > 8}
check("T7 never writes outside pages 1-8", sorted(outside), [])
# ── T7b: garbage geometry is REJECTED, not obeyed ────────────────
# Without the sanity check, BP=0 (page 0 is the TX buffer) or an RHBP
# below BP gives a zero or negative ring size and the assembler writes
# outside it.
for bp, rhbp, why in ((0, 15, "BP=0 is the TX buffer"),
(8, 4, "RHBP below BP"),
(1, 0x20, "RHBP beyond SPRAM ring")):
ctx.set(dut.bp, bp)
ctx.set(dut.rhbp, rhbp)
await tick(4)
if ctx.get(dut.geom_honoured):
check(f"T7b reject {why}", "honoured", "rejected")
check("T7b garbage geometry rejected", ctx.get(dut.geom_honoured), 0)
# ... and with it rejected, the default layout still works.
ctx.set(dut.rrp, 1)
await tick(4)
mem.clear()
await send_frame([0x5A] * 32)
check("T7b still stores with fallback layout", await wait_done(),
"stored")
fb = {a >> 8 for a in mem}
print(f" T7b fallback pages: {sorted(fb)}")
check("T7b fallback wrote something", len(fb) > 0, True)
check("T7b fallback writes inside pages 1-15",
sorted(p for p in fb if p < 1 or p > 15), [])
ctx.set(dut.bp, 1)
ctx.set(dut.rhbp, 15)
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.")