From 8825cfb3d6218214aa3ae7a2e88c503908a76edf Mon Sep 17 00:00:00 2001 From: Dennis Brentjes Date: Sat, 10 Oct 2026 20:17:22 +0200 Subject: [PATCH] bba_top: document the integration, and retract a comment claiming an open bug 778 -> 837 lines, 25% -> 17% comment-only. The module docstring now carries what was scattered or missing: a data-flow diagram, all four clock domains with what each hosts, and the three remaining crossings with their primitives. The worst thing in this file was a testbench comment asserting a bug that no longer exists: "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." That is the MISO latency solved by the full-cycle-early launch. EXI_HALF=1 passes, and has for some time. A stale comment claiming a KNOWN FAILURE is worse than a verbose one: it tells the next reader the suite's hardest case is expected to be wrong. Other stale text: the class docstring documented only the W5500 pins while W5100 is the default back-end; "Pulse synchronizer connections" and "reg exi->sync FIFO read side" described clock domains that were merged; the testbench header claimed "all three clock domains" and a "capture domain"; and the RX loop's model was called a W5500 when it is the W5100 bus model. Structure: elaborate() is now _clock_domains, _submodules, _wire_pins, _wire_datapath and _wire_bringup, with the submodules passed as a bundle. Also documented, from an AST audit of the tree: w5500_clk/mosi/cs_n are written and read by nothing, because synth.py declares only W5100 pins. They are not dead -- they are the alternate back-end's interface, exercised by w5500_spi_master's own testbench -- so the docstring now says that wiring them up is a synth.py change rather than leaving a reader to wonder. MEASURED AND REJECTED: the FFSynchronizer on cs_active into the status panel is NOT a clock-domain crossing, since ExiCapture already synchronises CS, so I removed it as redundant. Building it showed why it should stay -- area went 4305 -> 4330 LC, because the panel's combinational cone then reaches back to ExiCapture's own synchroniser. `sync` is the binding timing domain here, so 25 LC is not worth two flip-flops. Reverted, and the comment now explains what those registers actually buy instead of mislabelling them as a CDC. VERIFIED EQUIVALENT: synthesised cell counts identical to the original in every category (5971 cells; 134 SB_DFF, 3382 SB_LUT4, 669 SB_CARRY, 70 SB_RAM40_4K), so this commit changes no hardware at all. Full module suite passes and bba_top T9 is 6/6 at EXI_HALF=1, 2 and 8. Co-Authored-By: Claude Opus 5 --- exi_bba/bba_top.py | 467 +++++++++++++++++++++++++-------------------- 1 file changed, 263 insertions(+), 204 deletions(-) diff --git a/exi_bba/bba_top.py b/exi_bba/bba_top.py index 51a72c4..0d0be72 100644 --- a/exi_bba/bba_top.py +++ b/exi_bba/bba_top.py @@ -1,18 +1,47 @@ -"""BBATop — top-level elaboratable for the GC BBA FPGA replacement. +"""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 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. + 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 -Submodule instantiation and signal wiring ------------------------------------------ -See CLAUDE.md "Module Breakdown" and "CDC Signal Inventory" for the full list. +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 @@ -32,20 +61,48 @@ __all__ = ["BBATop"] class BBATop(Elaboratable): - """Top-level module. Wires all submodules and defines clock domains. + """Top-level module: creates `sync`, builds every submodule and wires them. - 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) + 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. - 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) + 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, @@ -55,38 +112,32 @@ class BBATop(Elaboratable): 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. + # 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 passed to the ethernet master (~1 ms on hardware; - # the testbench overrides with a small value for fast simulation). + 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 - # 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. + # 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) # for an SB_IO output register + self.exi_miso_next = Signal(init=1) self.exi_clk = Signal(init=1) self.exi_mosi = Signal() self.exi_cs_n = Signal(init=1) @@ -94,7 +145,6 @@ class BBATop(Elaboratable): self.int_n = Signal(init=1) if eth == "w5500": - # W5500 SPI self.w5500_clk = Signal() self.w5500_mosi = Signal() self.w5500_miso = Signal() @@ -102,10 +152,6 @@ class BBATop(Elaboratable): 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() @@ -117,52 +163,54 @@ class BBATop(Elaboratable): 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) + self.panel_led = Signal(5) + self.panel_btn = Signal(3) if uart_console or uart_shell: - self.uart_tx = Signal(init=1) # FPGA → PC (FT2232H Channel B) + # 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 - # ── 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) + def _clock_domains(self, m, platform): + """Create `sync` from the internal oscillator. - # ── Submodules ──────────────────────────────────────────────────── + 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() @@ -181,39 +229,29 @@ class BBATop(Elaboratable): m.submodules.drain = drain m.submodules.eth = eth - # ── External pin connections ────────────────────────────────────── + 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 += [ - # EXI inputs (to capture-domain front-end) - cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains + 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), - # EXI outputs - self.exi_miso.eq(cap.spi_miso), + 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), - # Ring-overflow protection: the assembler needs the GC's read - # pointer to know what it must not overwrite, and reports a - # dropped frame back as RBFI (IR[7]). - asm.rrp.eq(reg.rrp), - # The GC's own ring geometry (BP / RHBP). Previously hardcoded in - # the assembler, so a driver configuring a smaller RX ring was - # ignored and we kept writing pages it would never read. The - # assembler sanity-checks these and falls back to pages 1-15 if - # they are unusable -- see docs/bba_compat_todo.md item 2. - asm.bp .eq(reg.bp), - asm.rhbp.eq(reg.rhbp), - asm.ncra_rst.eq(reg.ncra_rst_o), - reg.rx_overflow.eq(asm.rx_overflow), - self.int_n .eq(reg.exi_int_n), + self.int_n .eq(mods.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_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: @@ -229,33 +267,45 @@ class BBATop(Elaboratable): self.w5100_rst_n .eq(eth.w5100_rst_n), ] - # ── ExiCapture byte stream ↔ BBARegisterFile (exi domain) ──────── + 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), + 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), + 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) + # 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), ] - # ── BBARegisterFile ↔ SPRAMArbiter (sync domain FIFO sides) ────── - # SPRAM request: reg exi→sync FIFO read side → arb + # 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), + 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) ────── + # 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), @@ -264,70 +314,74 @@ class BBATop(Elaboratable): 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), + 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), + eth.tx_sof .eq(drain.tx_sof), + eth.tx_eof .eq(drain.tx_eof), ] - # ── ethernet master → RXFrameAssembler (sync domain) ───────────── + # 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), - ] - # ── 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 ──────────────────────────────── + # 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 += [ - # 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). + 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), ] - # ── 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) - # NCRB.PR (bit 0) -> MACRAW MAC filter. Low means the ethernet chip - # delivers own-MAC + broadcast only, matching the real BBA; the GC can - # ask for promiscuous by setting PR. See the S0_MODE comment in - # w5100_parallel_master.py -- running unfiltered hung Swiss. + # 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 += eth.promisc.eq(reg.ncrb[NCRB_PR]) + m.d.comb += mods.eth.promisc.eq(reg.ncrb[NCRB_PR]) - # ── 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. + def _wire_bringup(self, m, mods): + """Optional status panel, UART console and UART shell. - init_req = reg.ncra_rst_o # base: GC-issued NCRA reset + `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() @@ -337,11 +391,18 @@ class BBATop(Elaboratable): m.submodules.panel = panel init_req = init_req | panel.reinit - # cs_active lives in the exi domain; bring it to sync for the LED. + # 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), @@ -366,29 +427,31 @@ class BBATop(Elaboratable): ] 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. + # `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), - # 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), + 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: @@ -399,13 +462,11 @@ class BBATop(Elaboratable): 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. +# 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 @@ -416,38 +477,36 @@ if __name__ == "__main__": status_panel=True) # also exercise the panel wiring errors = [] - # Capture ticks per SPI half-period -> EXI rate = 54 MHz / (2 * HALF). + # 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 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. + # 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 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 + # 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.""" - # 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. + # 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 @@ -621,7 +680,7 @@ if __name__ == "__main__": await ctx.delay(HP * (HALF)) # ── T6: FULL ETHERNET→SPRAM→GC LOOP ────────────────────────────── - # A frame arrives from the network (W5500 model) → W5500 master reads it + # 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)