d83f7beef8
The assembler wrote 00 00 len[11:8] len[7:0] with bytes 0 and 1 zero. The real format, read out of libogc's own BBA driver, is a PACKED 32-BIT LITTLE-ENDIAN word: next_packet_ptr in bits 0-11, packet_len in bits 12-23, status in bits 24-31. Under that driver our descriptor for a 342-byte frame decoded as a 12-byte frame with a next-pointer of 0 into the reserved page, so Swiss and anything else built on libogc would have initialised perfectly and then misparsed every received frame. Found by disassembling /opt/devkitpro/libogc/lib/cube/libbba.a (gcif.o, bba_interrupt) rather than by guessing: lwbrx r9,0,r9 # 4 bytes as a little-endian word rlwinm r4,r9,20,20,31 # packet_len = (word >> 12) & 0xFFF addi r4,r4,-4 # payload = packet_len - 4 cmpwi r4,1540 srwi r9,r9,24 # status = word >> 24 and. r9,r9,r18 # r18 = 0x..84 -> errors are 0x80 | 0x04 and the low 12 bits are written straight back to RRP (0x0018/0x0019), which is why next_packet_ptr has to be there at all -- it is the driver's read pointer. The length semantics and addressing were already correct (descriptor at page<<8, body at +4, length including the descriptor); only the packing was wrong and next_packet_ptr was never written. RXFrameAssembler now computes the next page BEFORE writing the descriptor (CALC_NEXT, formerly ADVANCE_RWP, moved ahead of the backfill) and writes bytes 0-3 as WRITE_D0..WRITE_D3. WRITE_D3 fixes a latent bug this exposed: WRITE_PLACEHOLDER issues four writes but the first lands twice, so desc_base+3 was NEVER written. That was harmless while the length write covered byte 3. As the status byte it would have been whatever the previous frame left in that cell, and a stale 0x80 or 0x04 makes the driver discard the frame. Readers updated to decode identically: netboot (which also now takes RRP from the descriptor's own next_packet_ptr, as libogc does, instead of recomputing the page span) and t_dma, including its page map. netboot's +0/+1 offset probe is removed -- the underrun that shifted descriptors is fixed and confirmed on console, and under a packed format a +1 read is not a shifted descriptor but garbage. The assembler's testbench now decodes with libogc_decode(), reproducing the instruction sequence above, so the tests check the DRIVER'S algorithm rather than our own packing. Every module testbench and bba_top at EXI_HALF=1/2/8 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
723 lines
34 KiB
Python
723 lines
34 KiB
Python
"""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)
|
||
-----------------------------------------------
|
||
A PACKED 32-BIT LITTLE-ENDIAN WORD, not a byte-wise layout:
|
||
|
||
bits 0–11 : next_packet_ptr — the PAGE at which the NEXT frame starts
|
||
bits 12–23 : packet_len — total bytes, INCLUDING these 4
|
||
bits 24–31 : status — errors are 0x80 and 0x04; 0x00 means good
|
||
|
||
so the four bytes at the page start are
|
||
|
||
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
|
||
Bytes 4+: raw ethernet frame
|
||
|
||
⚠️ This is NOT a guess and must not be "simplified" back to a plain
|
||
big-endian length. It was read out of libogc's own BBA driver
|
||
(`/opt/devkitpro/libogc/lib/cube/libbba.a`, `gcif.o`, `bba_interrupt`), which
|
||
is the driver GC software actually runs:
|
||
|
||
lwbrx r9,0,r9 # load the 4 bytes as a LITTLE-ENDIAN word
|
||
rlwinm r4,r9,20,20,31 # packet_len = (word >> 12) & 0xFFF
|
||
addi r4,r4,-4 # payload = packet_len - 4
|
||
cmpwi r4,1540 # ... which must be <= 1540
|
||
srwi r9,r9,24 # status = word >> 24
|
||
and. r9,r9,r18 # r18 = 0x...84, so errors are 0x80 | 0x04
|
||
|
||
and it writes the low 12 bits straight back to RRP as the next read pointer:
|
||
|
||
lwz r9,cur_descr # byte 0 -> register 0x0018 (RRP_LO)
|
||
rlwinm r9,r31,24,28,31 # bits 11:8 -> register 0x0019 (RRP_HI)
|
||
|
||
The length semantics and addressing already matched (descriptor at page<<8,
|
||
body at +4, length including the descriptor); the PACKING did not, and
|
||
`next_packet_ptr` was never written at all. Our previous
|
||
`00 00 len[11:8] len[7:0]` decodes under that driver as packet_len = 16 and
|
||
next_packet_ptr = 0 for a 342-byte frame — a 12-byte frame and a read pointer
|
||
into the reserved page.
|
||
|
||
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: compute the next page, then rewrite descriptor bytes 0, 1 and 2
|
||
with the packed next_packet_ptr + packet_len. Byte 3 (status) stays 0x00.
|
||
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()
|
||
|
||
# The GC's NCRA reset. MUST reset `rwp` here as well as the register
|
||
# file's copy: they are two 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 is pointing
|
||
# somewhere the writer is not, the free-space calculation shrinks with
|
||
# every frame, and once it falls below _MAX_FRAME_PAGES EVERY frame is
|
||
# dropped -- silently, because a dropped frame never reaches the ring
|
||
# to be counted. Hardware symptom: "0 seen, 0 bad desc, 0 lapped".
|
||
self.ncra_rst = 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)
|
||
# The page the NEXT frame will start at. Computed before the
|
||
# descriptor is written, because the descriptor carries it as
|
||
# next_packet_ptr -- libogc writes those 12 bits straight to RRP.
|
||
next_page = 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 the length write) 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 = "CALC_NEXT"
|
||
|
||
# ⚠️ All three descriptor 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("CALC_NEXT"):
|
||
# The descriptor carries next_packet_ptr, so the next page must
|
||
# be known BEFORE the descriptor is written -- this state used
|
||
# to come after it (as 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 += next_page.eq(next_rwp_raw - _PAGES_TOTAL)
|
||
with m.Else():
|
||
m.d.sync += next_page.eq(next_rwp_raw)
|
||
m.next = "WRITE_D0"
|
||
|
||
with m.State("WRITE_D0"):
|
||
# Descriptor byte 0 = 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"):
|
||
# Descriptor byte 1 = packet_len[3:0] << 4 | next_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"):
|
||
# Descriptor byte 2 = packet_len[11:4]. Byte 3 (status) keeps
|
||
# the placeholder 0x00, which libogc reads as "no error".
|
||
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"):
|
||
# Descriptor byte 3 = status = 0x00 ("no error": libogc tests
|
||
# it against 0x84).
|
||
#
|
||
# ⚠️ This MUST be written explicitly and cannot rely on the
|
||
# placeholder. WRITE_PLACEHOLDER issues four writes but the
|
||
# first lands twice (eth_wr_addr trails wr_addr by a cycle),
|
||
# so desc_base+3 is NEVER written. That was harmless while
|
||
# the length write covered byte 3; now byte 3 is the status
|
||
# and leaving it would hand libogc whatever the previous
|
||
# frame -- or power-on -- left in that SPRAM cell, and any
|
||
# frame landing on a stale 0x80 or 0x04 would be discarded as
|
||
# an error. The simulation model reports it as 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"
|
||
|
||
# ── NCRA reset: the write pointer goes back to the ring start ──────
|
||
# Placed AFTER the FSM so it takes priority over ADVANCE_RWP in the
|
||
# same cycle.
|
||
#
|
||
# This MUST happen here as well as in the register file: they are two
|
||
# 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 calculation shrinks with every frame, and once it drops
|
||
# below _MAX_FRAME_PAGES EVERY frame is dropped -- silently, because a
|
||
# dropped frame never reaches the ring to be counted. Hardware
|
||
# symptom: "timeout: no match (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 in
|
||
# IDLE.
|
||
with m.If(self.ncra_rst):
|
||
m.d.sync += rwp.eq(_RX_PAGE_FIRST)
|
||
|
||
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 pack_desc(total, next_ptr, status=0):
|
||
"""The 4 descriptor bytes libogc'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
|
||
# 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), 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. 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
|
||
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 ───────
|
||
# The register file resets its own copy of RWP on an NCRA reset; if
|
||
# this module does not reset its own, the two disagree and the GC ends
|
||
# up pointing somewhere the writer is not. The free-space sum then
|
||
# shrinks with every frame until every frame is dropped -- silently,
|
||
# which on hardware read as "0 seen, 0 bad desc, 0 lapped".
|
||
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)
|
||
|
||
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.")
|