diff --git a/exi_bba/rx_frame_assembler.py b/exi_bba/rx_frame_assembler.py index 8e2181f..1f2290b 100644 --- a/exi_bba/rx_frame_assembler.py +++ b/exi_bba/rx_frame_assembler.py @@ -333,25 +333,51 @@ if __name__ == "__main__": # dropped descriptor byte or a frame body written one byte early # changed nothing it looked at. mem = {} + order = [] # rx_irq and rx_overflow are 1-CYCLE pulses, and rx_overflow fires on # the very tick that consumes the EOF byte -- i.e. inside send_frame. # Polling for it afterwards misses it entirely, which first showed up # as a dropped frame looking like a stall. Latch both in tick(). pulse = {"irq": None, "ovf": False} + # eth_wr_ready stall pattern. Held constantly HIGH by every test this + # file ever had, which is a serious blind spot: on hardware the SPRAM + # arbiter stalls the write port constantly, because the GC is reading + # the ring over EXI while frames are being written into it. Any + # address bookkeeping that depends on ready timing is invisible until + # ready actually goes low. stall[0] = assert ready 1 cycle in N. + stall = [1] + cyc = [0] - async def tick(n=1): + 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): - mem[ctx.get(dut.eth_wr_addr)] = ctx.get(dut.eth_wr_data) + a, d = ctx.get(dut.eth_wr_addr), ctx.get(dut.eth_wr_data) + mem[a] = d + order.append((a, d)) await ctx.tick("sync") if ctx.get(dut.rx_overflow): pulse["ovf"] = True if ctx.get(dut.rx_irq): pulse["irq"] = ctx.get(dut.rx_wptr_w_data) + async def tick(n=1): + for _ in range(n): + apply_ready() + await tick_only() + + tag = ["?"] + async def send_frame(frame): pulse["irq"] = None pulse["ovf"] = False @@ -366,14 +392,23 @@ if __name__ == "__main__": ctx.set(dut.rx_valid, 1) ctx.set(dut.rx_sof, 1 if i == 0 else 0) ctx.set(dut.rx_eof, 1 if i == len(frame) - 1 else 0) + # Apply ready, THEN read rx_ready, THEN clock -- so the + # value the DUT decides on is the one this loop checked. + # Polling before applying had the loop waiting on a byte the + # DUT had already accepted: the frame stored correctly while + # send_frame reported rx_ready never asserted. guard = 0 - while not ctx.get(dut.rx_ready): - await tick() + while True: + apply_ready() + if ctx.get(dut.rx_ready): + break + await tick_only() guard += 1 - if guard > 400: - errors.append("send_frame: rx_ready never asserted") + if guard > 5000: + errors.append(f"send_frame[{tag[0]}] byte {i}: " + f"rx_ready never asserted") return - await tick() + await tick_only() ctx.set(dut.rx_valid, 0) ctx.set(dut.rx_sof, 0) ctx.set(dut.rx_eof, 0) @@ -405,13 +440,13 @@ if __name__ == "__main__": base = (page << 8) + 4 return [mem.get(base + i) for i in range(n)] - ctx.set(dut.eth_wr_ready, 1) 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 @@ -432,6 +467,7 @@ if __name__ == "__main__": # the ring. Every earlier test used a short frame, where a dropped # high byte is indistinguishable from a correct zero. ─────────────── f2 = [(i * 7 + 3) & 0xFF for i in range(300)] + tag[0] = "T2" await send_frame(f2) rwp2 = await wait_irq() total2 = len(f2) + 4 @@ -454,12 +490,14 @@ if __name__ == "__main__": 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": @@ -475,11 +513,34 @@ if __name__ == "__main__": # needs: free = (rrp - rwp - 1) mod 15, so rrp = 8 gives 8 free. ctx.set(dut.rrp, 8) await tick(4) + tag[0] = "T4-again" await send_frame(f3) check("T4 accepts again after RRP advances", await wait_done(), "stored") await tick(4) + # ── T5: the same frame WITH THE WRITE PORT STALLING ────────────── + # Models arbiter contention: ready asserted only 1 cycle in 3. The + # descriptor and body offset must be identical to the unstalled case. + # This is the case hardware runs in and no previous test ever did -- + # hardware reported a frame whose body was one byte late while + # simulation with ready held high was byte-perfect. + ctx.set(dut.rrp, 1) + await tick(2) + stall[0] = 3 + f5 = [(i * 5 + 9) & 0xFF for i in range(300)] + tag[0] = "T5" + await send_frame(f5) + rwp5 = await wait_done() + check("T5 stored despite stalls", rwp5, "stored") + pg5 = (ctx.get(dut.rx_wptr_w_data) - 2) & 0xFF # page it was written to + total5 = len(f5) + 4 + check("T5 descriptor under stall", descriptor(pg5), + [0x00, 0x00, (total5 >> 8) & 0xFF, total5 & 0xFF]) + check("T5 body at base+4 under stall", body(pg5, len(f5)), f5) + stall[0] = 1 + await tick(4) + # ── T3: RX disabled — SOF must be ignored ──────────────────────── before = len(mem) ctx.set(dut.rx_enabled, 0)