20d64ac416
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>
819 lines
36 KiB
Python
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.")
|