"""BBATop — top-level elaboratable for the GC BBA FPGA replacement. Clock domains ------------- sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01). Everything except the EXI bit engine. No PLL, no crystal. exiclk : the GC's EXI clock pin itself (~27 MHz), posedge — created inside exiclk_n : the same pin, negedge ................ `ExiCapture`. exififo_* the same pin, posedge, reset-less — the byte-FIFO sides. Submodule instantiation and signal wiring ----------------------------------------- See CLAUDE.md "Module Breakdown" and "CDC Signal Inventory" for the full list. """ from amaranth import * from exi_bba.exi_capture import ExiCapture from exi_bba.bba_register_file import BBARegisterFile from exi_bba.spram_arbiter import SPRAMArbiter from exi_bba.rx_frame_assembler import RXFrameAssembler from exi_bba.tx_frame_drain import TXFrameDrain from exi_bba.w5500_spi_master import W5500SPIMaster from exi_bba.w5100_parallel_master import W5100ParallelMaster from exi_bba.status_panel import StatusPanel from exi_bba.uart_console import UARTConsole from exi_bba.uart_shell import UARTShell from amaranth.lib.cdc import FFSynchronizer __all__ = ["BBATop"] class BBATop(Elaboratable): """Top-level module. Wires all submodules and defines clock domains. External ports (exposed for platform or testbench connection) ------------------------------------------------------------- EXI / GC interface (SPI Mode 3) exi_clk / exi_mosi / exi_cs_n : inputs from GC exi_miso : output to GC int_n : interrupt output (active low) W5500 SPI interface (SPI Mode 0) w5500_clk / w5500_mosi / w5500_cs_n : outputs to W5500 w5500_miso : input from W5500 w5500_int_n : W5500 interrupt (input, active low) w5500_rst_n : W5500 hardware reset (output, active low) """ def __init__(self, eth="w5100", reset_cycles=2_000_000, status_panel=False, uart_console=False, uart_shell=False, udp_socket=3, udp_src_ip="192.168.1.123", udp_subnet="255.255.255.0", udp_gateway="192.168.1.1", udp_dst_ip="192.168.1.100", udp_src_port=40000, udp_dst_port=6464, phase_meter=False): # Ethernet back-end: "w5100" (indirect parallel bus, reaches the EXI # ceiling) or "w5500" (SPI, ~12 Mbit/s). Both expose the identical # tx/rx/init/par interface, so only the physical pins differ. self._eth = eth self._phase_meter = phase_meter # diagnostic, see SPIMode3Slave # MR-reset settle wait passed to the ethernet master (~1 ms on hardware; # the testbench overrides with a small value for fast simulation). self._reset_cycles = reset_cycles # Optional bring-up status panel (drives onboard LEDs/button on the # iCEbreaker — see synth.py). panel_led bit order matches StatusPanel. self._status_panel = status_panel # Optional UART debug console (8N1 115200, sync domain). # uart_tx → FT2232H Channel B (net UART_RXD, pin 19); # uart_rx ← FT2232H Channel B (net UART_TXD, pin 18) — net names are # FTDI-perspective, so this is not a naming mismatch, see synth.py. self._uart_console = uart_console # Optional interactive UART command shell (bring-up UDP test on the # W5100's socket 1). Shares the UART pins with the console, so only one # may be enabled; the UDP path needs the W5100 back-end. See uart_shell. self._uart_shell = uart_shell if uart_console and uart_shell: raise ValueError("uart_console and uart_shell share the UART pins; " "enable only one") if uart_shell and eth != "w5100": raise ValueError("uart_shell UDP test requires eth='w5100'") self._udp_cfg = dict(udp_socket=udp_socket, src_ip=udp_src_ip, subnet=udp_subnet, gateway=udp_gateway, dst_ip=udp_dst_ip, src_port=udp_src_port, dst_port=udp_dst_port) # EXI (GC side) self.exi_miso_next = Signal(init=1) # for an SB_IO output register self.exi_clk = Signal(init=1) self.exi_mosi = Signal() self.exi_cs_n = Signal(init=1) self.exi_miso = Signal() self.int_n = Signal(init=1) if eth == "w5500": # W5500 SPI self.w5500_clk = Signal() self.w5500_mosi = Signal() self.w5500_miso = Signal() self.w5500_cs_n = Signal(init=1) self.w5500_int_n = Signal(init=1) self.w5500_rst_n = Signal(init=1) else: # W5100 indirect parallel bus. data_o/data_oe/data_i are the FPGA # side of a bidirectional D[7:0] (wrapped in a tristate SB_IO at the # platform level); a board ties the upper address lines to 0 so only # A[1:0] are wired. self.w5100_addr = Signal(2) self.w5100_data_o = Signal(8) self.w5100_data_oe = Signal() self.w5100_data_i = Signal(8) self.w5100_cs_n = Signal(init=1) self.w5100_rd_n = Signal(init=1) self.w5100_wr_n = Signal(init=1) self.w5100_int_n = Signal(init=1) self.w5100_rst_n = Signal(init=1) if status_panel: self.panel_led = Signal(5) # to onboard LEDs (see StatusPanel) self.panel_btn = Signal(3) # from onboard button(s) if uart_console or uart_shell: self.uart_tx = Signal(init=1) # FPGA → PC (FT2232H Channel B) self.uart_rx = Signal(init=1) # PC → FPGA def elaborate(self, platform): m = Module() # ── Clock domain generation ─────────────────────────────────────── # ONE generated domain, one physical source: # sync @ 24 MHz (internal SB_HFOSC ÷2) — register file, SPRAM, # RX/TX engines, ethernet master. Everything that is not the EXI # bit engine lives here. # # The EXI front-end is NOT clocked from here: `ExiCapture` builds its # own exiclk/exiclk_n domains directly from the GC's EXI clock pin. # That is what removed the old 54 MHz PLL-fed `capture` domain, which # oversampled the EXI clock 2× and put BIT-level timing across a CDC # boundary (~55 ns of synchroniser latency against a 37 ns bit) — the # cause of the `0x17` -> `05 C0` MISO shift on hardware. With the # engine clocked by the pin, MISO is clock-to-Q and the only crossings # left are byte-level FIFOs with a full byte-time of slack. # # Consequence for timing closure: `capture_clk` is GONE as a # constraint. It was the binding path for this whole design and the # reason builds only closed on a minority of seeds. if platform is not None: # sync @ 24 MHz from the internal oscillator. No PLL and no # external crystal are needed any more, so the 12 MHz input (pin # 35) and the PLL hard macro are both free. # The register-file / SPRAM / ethernet logic is routing-bound at # ~33–44 MHz on the UP5K; 24 MHz closes with large margin. The byte # rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 sync cycles per byte. m.domains += ClockDomain("sync") m.submodules.hfosc = Instance( "SB_HFOSC", p_CLKHF_DIV = "0b01", # 48 ÷ 2 → 24 MHz i_CLKHFEN = Const(1, 1), i_CLKHFPU = Const(1, 1), o_CLKHF = ClockSignal("sync"), ) # (simulation: the test harness drives the EXI pins and `sync` directly) # ── Submodules ──────────────────────────────────────────────────── cap = ExiCapture(phase_meter=self._phase_meter) reg = BBARegisterFile() arb = SPRAMArbiter() asm = RXFrameAssembler() drain = TXFrameDrain() eth = (W5500SPIMaster(reset_cycles=self._reset_cycles) if self._eth == "w5500" else W5100ParallelMaster(reset_cycles=self._reset_cycles, enable_udp_test=self._uart_shell, **self._udp_cfg)) m.submodules.cap = cap m.submodules.reg = reg m.submodules.arb = arb m.submodules.asm = asm m.submodules.drain = drain m.submodules.eth = eth # ── External pin connections ────────────────────────────────────── m.d.comb += [ # EXI inputs (to capture-domain front-end) cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains cap.spi_mosi.eq(self.exi_mosi), cap.spi_cs_n.eq(self.exi_cs_n), # EXI outputs self.exi_miso.eq(cap.spi_miso), self.exi_miso_next.eq(cap.miso_next), cap.id_phase.eq(reg.id_phase), self.int_n .eq(reg.exi_int_n), ] # Ethernet back-end physical pins if self._eth == "w5500": m.d.comb += [ self.w5500_clk .eq(eth.spi_clk), self.w5500_mosi.eq(eth.spi_mosi), self.w5500_cs_n.eq(eth.spi_cs_n), eth.spi_miso .eq(self.w5500_miso), eth.w5500_int_n.eq(self.w5500_int_n), self.w5500_rst_n.eq(eth.w5500_rst_n), ] else: m.d.comb += [ self.w5100_addr .eq(eth.bus_addr), self.w5100_data_o .eq(eth.bus_data_o), self.w5100_data_oe.eq(eth.bus_data_oe), eth.bus_data_i .eq(self.w5100_data_i), self.w5100_cs_n .eq(eth.cs_n), self.w5100_rd_n .eq(eth.rd_n), self.w5100_wr_n .eq(eth.wr_n), eth.w5100_int_n .eq(self.w5100_int_n), self.w5100_rst_n .eq(eth.w5100_rst_n), ] # ── ExiCapture byte stream ↔ BBARegisterFile (exi domain) ──────── m.d.comb += [ reg.rx_data .eq(cap.rx_data), reg.rx_rdy .eq(cap.rx_rdy), cap.rx_en .eq(reg.rx_en), cap.tx_data .eq(reg.tx_data), cap.tx_en .eq(reg.tx_en), reg.tx_rdy .eq(cap.tx_rdy), reg.cs_active.eq(cap.cs_active), # transaction-active (for DMA reads) ] # ── BBARegisterFile ↔ SPRAMArbiter (sync domain FIFO sides) ────── # SPRAM request: reg exi→sync FIFO read side → arb m.d.comb += [ arb.exi_req_addr .eq(reg.spram_req_r_data), arb.exi_req_valid.eq(reg.spram_req_r_rdy), reg.spram_req_r_en.eq(arb.exi_req_ready), ] # SPRAM response: arb result → reg sync→exi FIFO write side m.d.comb += [ reg.spram_rsp_data .eq(arb.exi_rsp_data), reg.spram_rsp_valid.eq(arb.exi_rsp_valid), ] # ── BBARegisterFile ↔ TXFrameDrain (sync domain FIFO sides) ────── m.d.comb += [ drain.tx_bytes_r_data.eq(reg.tx_bytes_r_data), drain.tx_bytes_r_rdy .eq(reg.tx_bytes_r_rdy), reg.tx_bytes_r_en .eq(drain.tx_bytes_r_en), drain.tx_ctrl_r_data.eq(reg.tx_ctrl_r_data), drain.tx_ctrl_r_rdy .eq(reg.tx_ctrl_r_rdy), reg.tx_ctrl_r_en .eq(drain.tx_ctrl_r_en), ] # ── TXFrameDrain ↔ ethernet master (sync domain) ────────────────── m.d.comb += [ eth.tx_data .eq(drain.tx_data), eth.tx_valid.eq(drain.tx_valid), drain.tx_ready.eq(eth.tx_ready), eth.tx_sof .eq(drain.tx_sof), eth.tx_eof .eq(drain.tx_eof), ] # ── ethernet master → RXFrameAssembler (sync domain) ───────────── m.d.comb += [ asm.rx_data .eq(eth.rx_data), asm.rx_valid.eq(eth.rx_valid), eth.rx_ready.eq(asm.rx_ready), asm.rx_sof .eq(eth.rx_sof), asm.rx_eof .eq(eth.rx_eof), ] # ── RXFrameAssembler → SPRAMArbiter (ETH write, sync domain) ───── m.d.comb += [ arb.eth_wr_addr .eq(asm.eth_wr_addr), arb.eth_wr_data .eq(asm.eth_wr_data), arb.eth_wr_valid.eq(asm.eth_wr_valid), asm.eth_wr_ready.eq(arb.eth_wr_ready), ] # ── RXFrameAssembler → BBARegisterFile (rx_wptr FIFO write side) ─ m.d.comb += [ reg.rx_wptr_w_data.eq(asm.rx_wptr_w_data), reg.rx_wptr_w_en .eq(asm.rx_wptr_w_en), asm.rx_wptr_w_rdy .eq(reg.rx_wptr_w_rdy), ] # ── Pulse synchronizer connections ──────────────────────────────── m.d.comb += [ # RX irq: sync → exi (RXFrameAssembler → reg → PS → exi domain) reg.rx_irq_i.eq(asm.rx_irq), # TX irq: sync → exi reg.tx_irq_i.eq(drain.tx_irq), # MAC address (PAR0–5) → SHAR. exi and sync share the HFOSC net, # and par is quasi-static (sampled by the master at init_req). eth.par.eq(reg.par), ] # ── RX enabled gate (NCRA SR / start-receive bit) ───────────────── # The RX ring-buffer path is active only after the GC sets NCRA[3]. m.d.comb += asm.rx_enabled.eq(reg.ncra_sr) # ── Optional bring-up peripherals (sync domain) ─────────────────── # Build init_req as an OR of all reinit sources (NCRA pulse plus any # manual re-init from the status panel and/or the UART 'r' command). # "ready" is latched high by eth.init_done and cleared by any init_req. # It is computed only when at least one peripheral needs it. init_req = reg.ncra_rst_o # base: GC-issued NCRA reset need_ready = self._status_panel or self._uart_console if need_ready: ready = Signal() if self._status_panel: panel = StatusPanel() m.submodules.panel = panel init_req = init_req | panel.reinit # cs_active lives in the exi domain; bring it to sync for the LED. cs_a_sync = Signal() m.submodules.panel_cs = FFSynchronizer( cap.cs_active, cs_a_sync, o_domain="sync") m.d.comb += [ panel.cs_active.eq(cs_a_sync), panel.rx_pulse .eq(asm.rx_irq), panel.tx_pulse .eq(drain.tx_irq), panel.ready .eq(ready), panel.btn .eq(self.panel_btn), self.panel_led .eq(panel.led), ] if self._uart_console: console = UARTConsole() m.submodules.console = console init_req = init_req | console.reinit m.d.comb += [ console.ncra_rst.eq(reg.ncra_rst_o), console.rx_pulse.eq(asm.rx_irq), console.tx_pulse.eq(drain.tx_irq), console.ready .eq(ready), self.uart_tx .eq(console.uart_tx), console.uart_rx .eq(self.uart_rx), ] if self._uart_shell: # Interactive command shell driving the W5100 socket-1 UDP test. # `eth` is the W5100 master built with enable_udp_test above. shell = UARTShell() m.submodules.shell = shell m.d.comb += [ self.uart_tx .eq(shell.uart_tx), shell.uart_rx .eq(self.uart_rx), # send eth.udp_send_req .eq(shell.udp_send_req), eth.udp_dst_ip .eq(shell.udp_dst_ip), eth.udp_pl_data .eq(shell.udp_pl_data), eth.udp_pl_valid .eq(shell.udp_pl_valid), eth.udp_pl_last .eq(shell.udp_pl_last), shell.udp_pl_ready .eq(eth.udp_pl_ready), shell.udp_test_busy .eq(eth.udp_test_busy), # receive (reply) eth.udp_rx_req .eq(shell.udp_rx_req), shell.udp_rx_busy .eq(eth.udp_rx_busy), shell.udp_rx_none .eq(eth.udp_rx_none), shell.udp_rx_data .eq(eth.udp_rx_data), shell.udp_rx_valid .eq(eth.udp_rx_valid), shell.udp_rx_eof .eq(eth.udp_rx_eof), eth.udp_rx_ready .eq(shell.udp_rx_ready), ] if need_ready: with m.If(eth.init_done): m.d.sync += ready.eq(1) with m.Elif(init_req): m.d.sync += ready.eq(0) m.d.comb += eth.init_req.eq(init_req) return m # ── Integration testbench ───────────────────────────────────────────────── # Drives real EXI Mode-3 transactions on the GC-facing pins and checks the # response — exercising the full chain ExiCapture (capture domain) ↔ byte FIFOs # ↔ BBARegisterFile (exi domain) ↔ sync modules, across all three clock domains. if __name__ == "__main__": import os import sys from amaranth.sim import Simulator, Period dut = BBATop(eth="w5100", reset_cycles=20, # small reset wait for sim status_panel=True) # also exercise the panel wiring errors = [] # Capture ticks per SPI half-period -> EXI rate = 54 MHz / (2 * HALF). # HALF=8 3.4 MHz fast regression default # HALF=2 13.5 MHz # HALF=1 27 MHz THE HARDWARE RATE # ⚠️ The default is 8, which clocks EXI EIGHT TIMES SLOWER than the real # GC. At that rate the whole CDC response latency hides inside a single # bit time, so this suite passed green while hardware failed outright -- # that is how both the header-format and pop-threshold bugs reached a # console. Run `EXI_HALF=1 python -m exi_bba.bba_top` before trusting any # timing-related change; at 27 MHz reads currently come back shifted right # by one bit (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an # open bug. HALF = int(os.environ.get("EXI_HALF", "8")) # The bit engine is now clocked by the EXI pin itself, so there is no # `capture` domain to count ticks against. Drive the SPI clock with real # delays: one half-period = HALF * (1/54 MHz), preserving the old meaning # of HALF so the rates in the table above still hold. HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n # Gap between transactions (CS high). cs_active reaches the exi domain # through a 2-stage FFSynchronizer at 24 MHz, so CS must stay high for # ~3 exi cycles or the register file never sees the transaction end and # swallows the next one's header as data. At EXI_HALF=1 one HP is only # 18.5 ns, far too short -- a real host leaves microseconds between # exi_deselect() and the next exi_select(). CS_GAP = Period(MHz=24) * 6 async def spi_byte(ctx, mosi_val): """Drive one EXI Mode-3 byte; return the assembled MISO byte.""" # Real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube is: # drive MOSI on the FALLING edge, sample MISO on the RISING edge. # The old version drove MOSI then fell -- a Mode 2 master -- which is # what the Pi probe did and why it never exposed the mismatch. miso = 0 for bit in range(7, -1, -1): ctx.set(dut.exi_clk, 0) # falling: master drives ctx.set(dut.exi_mosi, (mosi_val >> bit) & 1) await ctx.delay(HP * (HALF)) # Sample MISO in the CLK-LOW phase -- the value the host latches # ON the rising edge. The device launches MISO one bit EARLY, on # the rising edge (see spi_mode3_slave), so reading AFTER raising # the clock here would see the NEXT bit and the testbench would # report everything one bit early while hardware was correct. miso = (miso << 1) | ctx.get(dut.exi_miso) ctx.set(dut.exi_clk, 1) # rising: device samples await ctx.delay(HP * (HALF)) return miso async def exi_read(ctx, addr, length): """Register-space read, REAL protocol (docs/exi_protocol_real.md): 4-byte header [0x80, ah, al, 0x00] then a data phase that runs until CS. The trailing 0x00 is the device's prefetch window.""" ctx.set(dut.exi_cs_n, 0) ctx.set(dut.exi_clk, 1) await ctx.delay(HP * (HALF)) await spi_byte(ctx, 0x80) await spi_byte(ctx, (addr >> 8) & 0xFF) await spi_byte(ctx, addr & 0xFF) await spi_byte(ctx, 0x00) # EXI_Imm clock-idle gap: the core decodes the header and prefetches # responses into the tx FIFO before the GC clocks the data phase. await ctx.delay(HP * (HALF * 12)) result = [await spi_byte(ctx, 0x00) for _ in range(length)] ctx.set(dut.exi_cs_n, 1) await ctx.delay(CS_GAP) return result async def exi_write(ctx, addr, data): """Register-space write: 4-byte header [0xC0, ah, al, 0x00] + data.""" ctx.set(dut.exi_cs_n, 0) ctx.set(dut.exi_clk, 1) await ctx.delay(HP * (HALF)) await spi_byte(ctx, 0xC0) await spi_byte(ctx, (addr >> 8) & 0xFF) await spi_byte(ctx, addr & 0xFF) await spi_byte(ctx, 0x00) for b in data: await spi_byte(ctx, b) ctx.set(dut.exi_cs_n, 1) await ctx.delay(CS_GAP) async def exi_devid(ctx): """EXI_GetID: write 0x0000 (two bytes) then clock four read bytes.""" ctx.set(dut.exi_cs_n, 0) ctx.set(dut.exi_clk, 1) await ctx.delay(HP * (HALF)) await spi_byte(ctx, 0x00) await spi_byte(ctx, 0x00) await ctx.delay(HP * (HALF * 12)) out = [await spi_byte(ctx, 0x00) for _ in range(4)] ctx.set(dut.exi_cs_n, 1) await ctx.delay(CS_GAP) return out # ── W5100 indirect-bus slave model (drives w5100_data_i) ───────────── # Pre-loads a known MACRAW packet in the RX buffer so we can verify the full # ethernet→SPRAM→GC path. Same protocol as the W5100ParallelMaster bench. RX_FRAME = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04] _W_RX_BASE = 0x6000 # RX memory base as seen over the IDM (15-bit) bus _W_S0_CR = 0x0401 _W_S0_RX_RSR = 0x0426 _W_S0_RX_RD = 0x0428 _W_CR_RECV = 0x40 _A_MR, _A_AR0, _A_AR1, _A_DR = 0b00, 0b01, 0b10, 0b11 def w5100_preload(): plen = len(RX_FRAME) + 2 # MACRAW length includes its header mem = {} for i, b in enumerate([(plen >> 8) & 0xFF, plen & 0xFF] + RX_FRAME): mem[_W_RX_BASE + i] = b mem[_W_S0_RX_RSR], mem[_W_S0_RX_RSR + 1] = (plen >> 8) & 0xFF, plen & 0xFF mem[_W_S0_RX_RD], mem[_W_S0_RX_RD + 1] = 0, 0 return mem w5100_mem = w5100_preload() tx_seen = [] # bytes the model saw written into the TX buffer async def w5100_model(ctx): idm_ar = 0 mr = 0 prev_cs = prev_rd = prev_wr = 1 async for vals in ctx.tick("sync").sample( dut.w5100_cs_n, dut.w5100_rd_n, dut.w5100_wr_n, dut.w5100_addr, dut.w5100_data_o): cs, rd, wr, a, do = vals[-5:] ai = (mr >> 1) & 1 if cs == 0 and rd == 0: # drive read data if a == _A_MR: val = mr elif a == _A_AR0: val = (idm_ar >> 8) & 0xFF elif a == _A_AR1: val = idm_ar & 0xFF else: val = w5100_mem.get(idm_ar, 0) ctx.set(dut.w5100_data_i, val) if cs == 0 and prev_wr == 0 and wr == 1: # latch write on /WR rising if a == _A_MR: mr = do elif a == _A_AR0: idm_ar = (idm_ar & 0x00FF) | (do << 8) elif a == _A_AR1: idm_ar = (idm_ar & 0xFF00) | do else: w5100_mem[idm_ar] = do if 0x4000 <= idm_ar < 0x6000: # socket TX buffer region tx_seen.append(do) if idm_ar == _W_S0_CR and do == _W_CR_RECV: w5100_mem[_W_S0_RX_RSR] = 0 w5100_mem[_W_S0_RX_RSR + 1] = 0 if ai: idm_ar = (idm_ar + 1) & 0xFFFF if cs == 0 and prev_rd == 0 and rd == 1 and a == _A_DR and ai: idm_ar = (idm_ar + 1) & 0xFFFF prev_cs, prev_rd, prev_wr = cs, rd, wr async def testbench(ctx): ctx.set(dut.exi_clk, 1) ctx.set(dut.exi_cs_n, 1) ctx.set(dut.panel_btn, 0b111) # all buttons released (active-low idle) await ctx.delay(HP * (20)) # T1: device ID — read 4 bytes from addr 0 → 0x04 0x02 0x02 0x00 dev = await exi_devid(ctx) print(f"T1 device ID: {[f'0x{b:02X}' for b in dev]}") if dev != [0x04, 0x02, 0x02, 0x00]: errors.append(f"T1 device ID: got {dev}") await ctx.delay(HP * (HALF)) # T2: write PAR0–3, read them back through the full chain await exi_write(ctx, 0x20, [0xDE, 0xAD, 0xBE, 0xEF]) await ctx.delay(HP * (HALF * 4)) par = await exi_read(ctx, 0x20, 4) print(f"T2 PAR0-3 readback: {[f'0x{b:02X}' for b in par]}") if par != [0xDE, 0xAD, 0xBE, 0xEF]: errors.append(f"T2 PAR readback: got {par}") await ctx.delay(HP * (HALF)) # T3: NWAYS must read back the hardcoded 0x17 (link-up sentinel) nways = await exi_read(ctx, 0x31, 1) print(f"T3 NWAYS: 0x{nways[0]:02X} (want 0x17)") if nways != [0x17]: errors.append(f"T3 NWAYS: got {nways}") await ctx.delay(HP * (HALF)) # T4: DMA-style SPRAM read — clock 8 data bytes (past the 4-byte header # limit) within one CS. Exercises the integrated streaming path: # ExiCapture(cs_active) → register file SPRAM_STREAM → SPRAMArbiter → # real SPRAM → MISO, plus the SPRAM_END cleanup. SPRAM is uninitialised # here, so we check the stream completes (8 bytes, no underrun/hang) # rather than specific data. dma = await exi_read(ctx, 0x0100, 8) print(f"T4 DMA read (8B from 0x100): {[f'0x{b:02X}' for b in dma]}") if len(dma) != 8: errors.append(f"T4 DMA read length: got {len(dma)}") await ctx.delay(HP * (HALF)) # T5: a register read after the streaming read confirms the FSM cleaned # up (SPRAM_END → HEADER0) and the device is responsive again. nways2 = await exi_read(ctx, 0x31, 1) print(f"T5 NWAYS after DMA: 0x{nways2[0]:02X} (want 0x17)") if nways2 != [0x17]: errors.append(f"T5 NWAYS after DMA read: got {nways2}") await ctx.delay(HP * (HALF)) # ── T6: FULL ETHERNET→SPRAM→GC LOOP ────────────────────────────── # A frame arrives from the network (W5500 model) → W5500 master reads it # → RXFrameAssembler writes it to the SPRAM ring → GC reads RWP then # DMA-reads the descriptor+frame back. Exercises the entire RX path. # The W5100 needs its init sequence (which sets MR.AI / opens socket 0) # before multi-byte bus accesses work — trigger it via NCRA reset, as # the real GC driver does, and let it run before enabling RX. await exi_write(ctx, 0x00, [0x01]) # NCRA reset → init_req pulse await ctx.delay(HP * (2000)) # let W5100 init run await exi_write(ctx, 0x00, [0x08]) # NCRA SR bit → enable RX await ctx.delay(HP * (HALF * 2)) ctx.set(dut.w5100_int_n, 0) # W5100: a packet was received await ctx.delay(HP * (4000)) # let the W5100 RX + SPRAM write run ctx.set(dut.w5100_int_n, 1) await ctx.delay(HP * (HALF * 2)) rwp = await exi_read(ctx, 0x16, 1) # RX write pointer (page) total_len = len(RX_FRAME) + 4 got = await exi_read(ctx, 0x0100, total_len) # descriptor + frame want = [0x00, 0x00, (total_len >> 8) & 0xFF, total_len & 0xFF] + RX_FRAME print(f"T6 RWP=0x{rwp[0]:02X} (want 0x02)") print(f"T6 SPRAM[0x100]: {[f'0x{b:02X}' for b in got]}") print(f"T6 expected : {[f'0x{b:02X}' for b in want]}") if rwp != [0x02]: errors.append(f"T6 RWP: got {rwp}, want [0x02]") if got != want: errors.append(f"T6 RX frame mismatch:\n got {got}\n want {want}") # T7: status-panel integration — after all the EXI traffic above, the # EXI-activity LED (panel led[1] = stretched cs_active) must be lit, # proving cap.cs_active → FFSync → StatusPanel → LED is wired end-to-end. leds = ctx.get(dut.panel_led) if not (leds >> 1) & 1: errors.append(f"T7 panel: EXI-activity LED not lit (led=0b{leds:05b})") print(f"T7 panel led=0b{leds:05b} (bit1=EXI activity, expect 1)") # T8: GC → wire TX of a frame LONGER than the 16-deep tx_bytes FIFO. # This is the case that shipped broken: TXFrameDrain used to wait for # the length until the NCRA ST write, i.e. until after the GC had # streamed the whole frame, so the frame had to fit in tx_bytes and # everything past byte 16 was silently dropped (hardware 2026-09-30: # frames truncated at exactly 16 bytes on the wire). The length is now # queued on the TWD_HI write so the drain runs concurrently. TX_FRAME = [0xFF] * 6 + [0x00, 0x09, 0xBF, 0x00, 0x00, 0x01] + \ [0x88, 0xB5] + [0x40 + i for i in range(20)] # 34 bytes assert len(TX_FRAME) > 16, "test must exceed the tx_bytes depth to be meaningful" tx_seen.clear() await exi_write(ctx, 0x34, [len(TX_FRAME) & 0xFF]) await exi_write(ctx, 0x35, [(len(TX_FRAME) >> 8) & 0xFF]) for b in TX_FRAME: # One byte per transaction: eff_addr increments per data byte, so a # multi-byte write to 0x48 would spill into 0x49+. await exi_write(ctx, 0x48, [b]) await exi_write(ctx, 0x00, [0x08 | 0x02]) # SR + ST0 → transmit for _ in range(4000): if len(tx_seen) >= len(TX_FRAME): break await ctx.tick("sync") got = tx_seen[:len(TX_FRAME)] print(f"T8 TX {len(TX_FRAME)}B frame: W5100 got {len(tx_seen)} bytes") if got != TX_FRAME: n = min(len(got), len(TX_FRAME)) first = next((i for i in range(n) if got[i] != TX_FRAME[i]), n) errors.append(f"T8 TX frame mismatch: {len(got)}/{len(TX_FRAME)} bytes, " f"first bad byte at index {first}") print(f" got {[f'0x{b:02X}' for b in got[:20]]}") print(f" want {[f'0x{b:02X}' for b in TX_FRAME[:20]]}") else: print(f"T8 TX frame matches byte-for-byte ({len(TX_FRAME)} bytes)") sim = Simulator(dut) sim.add_clock(Period(MHz=24), domain="sync") sim.add_testbench(testbench) sim.add_process(w5100_model) sim.run() if errors: print("\nFAILURES:") for e in errors: print(" ", e) sys.exit(1) else: print("\nAll BBATop integration tests passed.")