"""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.")