"""BBATop — the whole adapter: GameCube EXI on one side, ethernet on the other. Data flow --------- GC ──EXI──> ExiCapture ──bytes──> BBARegisterFile ─┬─> TXFrameDrain ──> eth │ GC <─EXI─── ExiCapture <─bytes─── BBARegisterFile <─┴── SPRAMArbiter <─┐ │ RXFrameAssembler <──frames── ethernet master <─┘ The FPGA holds no network stack. The GC's own TCP/IP runs on its CPU and this is a MAC bridge: register reads and writes on one side, whole frames on the other, with the RX ring in SPRAM between them. Clock domains ------------- sync 24 MHz, from the internal SB_HFOSC (÷2, CLKHF_DIV=0b01). EVERYTHING except the EXI bit engine: register file, SPRAM arbiter, RX/TX engines, ethernet master, bring-up peripherals. exiclk the GC's EXI clock pin itself, ~27 MHz, posedge. Hosts only the SPI bit engine, and is async-reset by CS. exififo_w the same pin, posedge, reset-less -- the byte FIFOs' sides. exififo_r All four are created inside `ExiCapture`, which owns the pin-clocked front end; this module creates only `sync`. There is no PLL, no external crystal and no oversampling domain: bit-level timing across a CDC boundary is what made MISO arrive two bits late on hardware, so the engine is clocked by the pin and the only crossings left are byte-level. Crossings --------- Just three, all with a full byte time (~296 ns at 27 MHz) of slack: RX byte stream exififo_w → sync AsyncFIFO, 8-bit, depth 4 TX byte stream sync → exififo_r AsyncFIFO, 8-bit, depth 2 cs_active EXI pin → sync FFSynchronizer, 2 stages Everything that once crossed a separate `exi` domain is now same-domain: those FIFOs became `SyncFIFO`s and the pulse synchronisers became plain wires. """ from types import SimpleNamespace from amaranth import * from exi_bba.exi_capture import ExiCapture from exi_bba.bba_register_file import BBARegisterFile, NCRB_PR 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: creates `sync`, builds every submodule and wires them. GC side (EXI, SPI Mode 3) ------------------------- exi_clk, exi_mosi, exi_cs_n Inputs from the GC. `exi_clk` becomes a clock and `exi_cs_n` an async reset inside `ExiCapture`. exi_miso Serial output to the GC, driven from the fabric. The synthesised build does not use this -- see `exi_miso_next` -- but the testbench reads it, and `spi_mode3_slave` T6 proves the two agree. exi_miso_next The same bit one edge early, for an SB_IO output register in the IO tile. This is what `synth.py` actually drives the pad from. int_n Interrupt to the GC, active low. Ethernet side -- ONE of these two exists, chosen by `eth=` ---------------------------------------------------------- eth="w5100" (default): indirect parallel bus, reaches the ~27 Mbit/s EXI ceiling. w5100_addr A[1:0]; the board ties A[14:2] low w5100_data_o, w5100_data_oe, w5100_data_i FPGA side of a bidirectional D[7:0], a tristate SB_IO at the platform level w5100_cs_n, w5100_rd_n, w5100_wr_n bus strobes w5100_int_n, w5100_rst_n eth="w5500": SPI Mode 0, ~12 Mbit/s -- below the EXI ceiling, so it is the alternate rather than the default. w5500_clk, w5500_mosi, w5500_cs_n, w5500_miso w5500_int_n, w5500_rst_n ⚠️ `synth.py` only declares W5100 pins, so the W5500 outputs reach no pad in a real build. They are the module's interface, exercised by the `w5500_spi_master` testbench; wiring them up is a synth.py change. Optional bring-up peripherals ----------------------------- panel_led, panel_btn with status_panel=True; see `StatusPanel` uart_tx, uart_rx with uart_console=True or uart_shell=True. Both share the one UART, so only one may be enabled. """ 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): # Both masters expose an identical tx/rx/init/par interface, so only # the physical pins below differ. self._eth = eth self._phase_meter = phase_meter # diagnostic; see SPIMode3Slave # MR-reset settle wait handed to the ethernet master (~1 ms of cycles # on hardware; the testbench passes a tiny value to keep sim short). self._reset_cycles = reset_cycles self._status_panel = status_panel self._uart_console = uart_console # Interactive shell driving a UDP test on a second ethernet socket, # which needs the W5100's multi-socket support. 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) 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": 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: 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) self.panel_btn = Signal(3) if uart_console or uart_shell: # Net names on the board are from the FT2232H's perspective, so # the FPGA's TX pin carries the net called UART_RXD. See synth.py. self.uart_tx = Signal(init=1) # FPGA → PC self.uart_rx = Signal(init=1) # PC → FPGA def elaborate(self, platform): m = Module() self._clock_domains(m, platform) mods = self._submodules(m) self._wire_pins(m, mods) self._wire_datapath(m, mods) self._wire_bringup(m, mods) return m def _clock_domains(self, m, platform): """Create `sync` from the internal oscillator. This module generates ONE domain from ONE physical source. The EXI front end is deliberately not clocked from here: `ExiCapture` builds its domains straight off the GC's clock pin, which is what removed the old PLL-fed 54 MHz oversampling domain -- that domain was the binding timing path for the entire design and the reason builds closed on only a minority of seeds. 24 MHz is chosen with margin: the register file, SPRAM and ethernet logic are routing-bound around 33-44 MHz on this part, and the EXI byte rate (27 MHz / 8 ≈ 3.4 MHz) leaves roughly seven sync cycles per byte. In simulation the harness drives `sync` and the EXI pins itself, so nothing is instantiated. """ if platform is None: return 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"), ) def _submodules(self, m): """Build every submodule and register it. Returns them as a bundle.""" 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 return SimpleNamespace(cap=cap, reg=reg, arb=arb, asm=asm, drain=drain, eth=eth) def _wire_pins(self, m, mods): """Connect the external pins to the front end and the ethernet chip.""" cap, eth = mods.cap, mods.eth m.d.comb += [ cap.spi_clk .eq(self.exi_clk), # becomes the exiclk domain cap.spi_mosi.eq(self.exi_mosi), cap.spi_cs_n.eq(self.exi_cs_n), # becomes its async reset self.exi_miso .eq(cap.spi_miso), self.exi_miso_next.eq(cap.miso_next), self.int_n .eq(mods.reg.exi_int_n), ] 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), ] def _wire_datapath(self, m, mods): """Wire the EXI byte streams, SPRAM, and both frame directions. Everything below `ExiCapture` is one `sync` domain, so these are plain combinational connections and same-domain FIFO sides -- no CDC. """ cap, reg, arb = mods.cap, mods.reg, mods.arb asm, drain, eth = mods.asm, mods.drain, mods.eth # ExiCapture byte streams ↔ register file 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), # Transaction-in-progress level: a register-space read at or above # 0x100 streams from SPRAM until CS rises, so the register file # needs to see the end of the transaction. reg.cs_active.eq(cap.cs_active), ] # Register file ↔ SPRAM arbiter. Requests come through a SyncFIFO; # responses are COMBINATIONAL and must stay that way -- see the note # on BBARegisterFile._read_mux. 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), reg.spram_rsp_data .eq(arb.exi_rsp_data), reg.spram_rsp_valid.eq(arb.exi_rsp_valid), ] # TX: register file → drain → ethernet. tx_bytes carries the frame and # tx_ctrl its length, so the drain can start before the frame ends. 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), 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), ] # RX: ethernet → assembler → SPRAM ring, then the pointer back to the # register file so the GC can see the new frame. 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), 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), 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), ] # Interrupt sources, and the ring state the assembler needs from the # GC: its read pointer (so unread pages are not overwritten) and the # ring geometry it asked for. m.d.comb += [ reg.rx_irq_i.eq(asm.rx_irq), reg.tx_irq_i.eq(drain.tx_irq), reg.rx_overflow.eq(asm.rx_overflow), asm.rrp .eq(reg.rrp), asm.bp .eq(reg.bp), asm.rhbp.eq(reg.rhbp), asm.ncra_rst.eq(reg.ncra_rst_o), # The MAC the GC holds in PAR0-5 becomes the ethernet chip's own # source address. Quasi-static: the master samples it at init. eth.par.eq(reg.par), # RX runs only once the GC sets NCRA's SR (start-receive) bit. asm.rx_enabled.eq(reg.ncra_sr), ] # NCRB's PR bit asks for promiscuous receive. Low -- the normal case # -- leaves the chip filtering to own-MAC plus broadcast, which is what # the real BBA does; running unfiltered livelocked the GC's own # interrupt handler. See the S0_MR note in w5100_parallel_master. if self._eth == "w5100": m.d.comb += mods.eth.promisc.eq(reg.ncrb[NCRB_PR]) def _wire_bringup(self, m, mods): """Optional status panel, UART console and UART shell. `init_req` to the ethernet master is the OR of every re-init source: the GC's NCRA reset, plus any manual request from the panel button or the console's 'r' command. `ready` is latched by `init_done` and cleared by any new request, and is only built when something displays it. """ cap, reg, asm, drain, eth = (mods.cap, mods.reg, mods.asm, mods.drain, mods.eth) init_req = reg.ncra_rst_o # the GC's own 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 # NOT a clock-domain crossing, despite the primitive: # `cap.cs_active` is already a sync-domain signal, because # ExiCapture synchronises CS itself. What these two registers buy # is a short input path into the panel's logic, and that is why # they stay -- deleting them cost 25 LC (4305 -> 4330) when # measured, since the panel's combinational cone then reaches back # to ExiCapture's own synchroniser. `sync` is the binding timing # domain in this design, so that is not a trade worth making for # two flip-flops. 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: # `eth` here is the W5100 master, built with enable_udp_test so it # carries the extra socket the shell drives. The UDP test is the # lowest-priority branch of that master's bus arbiter, so a GC # frame always preempts it. shell = UARTShell() m.submodules.shell = shell m.d.comb += [ self.uart_tx .eq(shell.uart_tx), shell.uart_rx.eq(self.uart_rx), 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), 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) # ── Integration testbench ───────────────────────────────────────────────── # Drives real EXI Mode-3 transactions on the GC-facing pins and checks what # comes back, exercising the whole chain: the pin-clocked bit engine, the two # byte FIFOs that cross into `sync`, and every sync-domain module behind them. 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 = [] # Delay units 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 response latency hides inside a single bit # time, so this suite can pass green while hardware fails 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. All three rates pass today. HALF = int(os.environ.get("EXI_HALF", "8")) # The bit engine is clocked by the EXI pin, so there is no faster domain # to count ticks against. The SPI clock is driven with real delays # instead: one half-period is HALF * (1/54 MHz), which keeps the rates in # the table above meaning what they say. HP = Period(MHz=54) # the unit of delay; half-periods are HP * HALF # Gap between transactions (CS high). cs_active reaches `sync` through a # 2-stage FFSynchronizer at 24 MHz, so CS must stay high for ~3 sync # 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.""" # A real Mode 3 master (CPOL=1, CPHA=1), which is what the GameCube # is: drive MOSI on the FALLING edge, and sample MISO in the clock-LOW # phase. A master that parks MOSI early cannot tell Mode 2 from # Mode 3, which is how that mix-up once reached hardware. 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 (W5100 bus model) → the 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)) # T1b: the device ID STILL ANSWERS after an NCRA reset. # # It used to be a one-shot: `id_phase` was cleared by the GC's first # NCRA reset, after which a command-space read of reg 0 returned # 0x00000000. Real hardware answers EXI_GetID whenever asked -- that # is how EXI enumerates devices -- and Swiss calls it from # bba_exists() AND again from bba_requires_init(), long after init. # With the one-shot spent, Swiss reported NO ADAPTER AT ALL. # # T1 above reads the ID before any reset, so it cannot catch this; # the reset two lines up is what makes this check meaningful. dev2 = await exi_devid(ctx) print(f"T1b device ID after NCRA reset: {[f'0x{b:02X}' for b in dev2]}") if dev2 != [0x04, 0x02, 0x02, 0x00]: errors.append(f"T1b device ID after NCRA reset: got {dev2} " f"- the one-shot is back, Swiss would see no adapter") 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 RX_NEXT_PAGE = 2 # one-page frame written at page 1 got = await exi_read(ctx, 0x0100, total_len) # descriptor + frame # The descriptor is a packed little-endian word, as libogc's driver # reads it: next_packet_ptr[11:0] | packet_len[23:12] | status[31:24]. # See the format note in rx_frame_assembler.py. _desc_word = ((total_len & 0xFFF) << 12) | (RX_NEXT_PAGE & 0xFFF) want = [(_desc_word >> (8 * i)) & 0xFF for i in range(4)] + 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)") # ── T9: EXI DMA READ **WHILE** THE ETHERNET SIDE WRITES THE RING ── # This is the condition hardware actually runs in and that no test has # ever modelled: T6 above waits for the frame to finish before reading, # so reads and writes never overlap. # # ETH has ABSOLUTE priority in SPRAMArbiter -- eth_wr_ready is asserted # combinationally whenever eth_wr_valid is high -- so an EXI read is # preempted whenever a frame byte is being written. If a read request # is lost, or a response is delayed past the end of its transaction, # the GC's byte stream shifts. # # On console the ring descriptor lands at page offset +1 instead of +0 # for ~77% of frames (desc@+0=100 +1=343), varying frame to frame, and # netboot only works because its reader probes both offsets. Swiss # does not probe: it would read a frame length of 1 and the RX path # would be broken for real networking. So this test exists to # reproduce that, not merely to add coverage. # # Page 1 still holds T6's completed frame, so its expected contents are # known exactly while a SECOND frame is being written behind it. ctx.set(dut.w5100_int_n, 0) # another frame arriving mismatches = 0 first_bad = None # Also read the FRAME BODY at 0x104, whose first byte is 0xDE. The # descriptor at 0x100 starts with 0x00, which is indistinguishable from # a stale/empty byte -- so reading a region with a known NON-ZERO first # byte is what identifies whether slot 0 holds a leftover and, from its # value, where that leftover came from. body_reads = [] for attempt in range(6): again = await exi_read(ctx, 0x0100, total_len) if again != want: mismatches += 1 if first_bad is None: first_bad = again body_reads.append(await exi_read(ctx, 0x0104, 6)) print(f"T9 body reads from 0x104 (want DE AD BE EF ..):") for i, b in enumerate(body_reads): print(f" [{i}] {' '.join(f'{x:02X}' for x in b)}") ctx.set(dut.w5100_int_n, 1) await ctx.delay(HP * (HALF * 4)) print(f"T9 concurrent DMA reads: {6 - mismatches}/6 byte-exact") if mismatches: print(f"T9 first bad read: {[f'0x{b:02X}' for b in first_bad[:8]]}") print(f"T9 expected : {[f'0x{b:02X}' for b in want[:8]]}") errors.append(f"T9 DMA read corrupted by concurrent ETH writes " f"({mismatches}/6 reads wrong)") # 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.")