Fix the RX ring descriptor to the format libogc actually parses

The assembler wrote 00 00 len[11:8] len[7:0] with bytes 0 and 1 zero.  The real
format, read out of libogc's own BBA driver, is a PACKED 32-BIT LITTLE-ENDIAN
word: next_packet_ptr in bits 0-11, packet_len in bits 12-23, status in bits
24-31.  Under that driver our descriptor for a 342-byte frame decoded as a
12-byte frame with a next-pointer of 0 into the reserved page, so Swiss and
anything else built on libogc would have initialised perfectly and then
misparsed every received frame.

Found by disassembling /opt/devkitpro/libogc/lib/cube/libbba.a (gcif.o,
bba_interrupt) rather than by guessing:

  lwbrx   r9,0,r9              # 4 bytes as a little-endian word
  rlwinm  r4,r9,20,20,31       # packet_len = (word >> 12) & 0xFFF
  addi    r4,r4,-4             # payload = packet_len - 4
  cmpwi   r4,1540
  srwi    r9,r9,24             # status = word >> 24
  and.    r9,r9,r18            # r18 = 0x..84 -> errors are 0x80 | 0x04

and the low 12 bits are written straight back to RRP (0x0018/0x0019), which is
why next_packet_ptr has to be there at all -- it is the driver's read pointer.

The length semantics and addressing were already correct (descriptor at
page<<8, body at +4, length including the descriptor); only the packing was
wrong and next_packet_ptr was never written.

RXFrameAssembler now computes the next page BEFORE writing the descriptor
(CALC_NEXT, formerly ADVANCE_RWP, moved ahead of the backfill) and writes bytes
0-3 as WRITE_D0..WRITE_D3.

WRITE_D3 fixes a latent bug this exposed: WRITE_PLACEHOLDER issues four writes
but the first lands twice, so desc_base+3 was NEVER written.  That was harmless
while the length write covered byte 3.  As the status byte it would have been
whatever the previous frame left in that cell, and a stale 0x80 or 0x04 makes
the driver discard the frame.

Readers updated to decode identically: netboot (which also now takes RRP from
the descriptor's own next_packet_ptr, as libogc does, instead of recomputing
the page span) and t_dma, including its page map.  netboot's +0/+1 offset probe
is removed -- the underrun that shifted descriptors is fixed and confirmed on
console, and under a packed format a +1 read is not a shifted descriptor but
garbage.

The assembler's testbench now decodes with libogc_decode(), reproducing the
instruction sequence above, so the tests check the DRIVER'S algorithm rather
than our own packing.  Every module testbench and bba_top at EXI_HALF=1/2/8
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 10:27:45 +02:00
parent 5ee1b9d07b
commit d83f7beef8
6 changed files with 273 additions and 79 deletions
+136 -32
View File
@@ -11,17 +11,50 @@ Page wrap: after 0x0F → 0x01 (skip 0x00).
Frame descriptor (4 bytes at page start)
-----------------------------------------------
Byte 0: LRPS (last received packet status) — 0x00
Byte 1: 0x00
Byte 2: total_length[15:8] (big-endian; includes 4 descriptor bytes)
Byte 3: total_length[7:0]
Bytes 4+: raw ethernet frame
A PACKED 32-BIT LITTLE-ENDIAN WORD, not a byte-wise layout:
bits 0–11 : next_packet_ptr — the PAGE at which the NEXT frame starts
bits 12–23 : packet_len — total bytes, INCLUDING these 4
bits 24–31 : status — errors are 0x80 and 0x04; 0x00 means good
so the four bytes at the page start are
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
Bytes 4+: raw ethernet frame
⚠️ This is NOT a guess and must not be "simplified" back to a plain
big-endian length. It was read out of libogc's own BBA driver
(`/opt/devkitpro/libogc/lib/cube/libbba.a`, `gcif.o`, `bba_interrupt`), which
is the driver GC software actually runs:
lwbrx r9,0,r9 # load the 4 bytes as a LITTLE-ENDIAN word
rlwinm r4,r9,20,20,31 # packet_len = (word >> 12) & 0xFFF
addi r4,r4,-4 # payload = packet_len - 4
cmpwi r4,1540 # ... which must be <= 1540
srwi r9,r9,24 # status = word >> 24
and. r9,r9,r18 # r18 = 0x...84, so errors are 0x80 | 0x04
and it writes the low 12 bits straight back to RRP as the next read pointer:
lwz r9,cur_descr # byte 0 -> register 0x0018 (RRP_LO)
rlwinm r9,r31,24,28,31 # bits 11:8 -> register 0x0019 (RRP_HI)
The length semantics and addressing already matched (descriptor at page<<8,
body at +4, length including the descriptor); the PACKING did not, and
`next_packet_ptr` was never written at all. Our previous
`00 00 len[11:8] len[7:0]` decodes under that driver as packet_len = 16 and
next_packet_ptr = 0 for a 342-byte frame — a 12-byte frame and a read pointer
into the reserved page.
Write sequence
--------------
1. Issue 4 SPRAM writes of 0x00 (placeholder descriptor).
2. For each byte received from W5500, issue one SPRAM write.
3. After EOF: rewrite descriptor bytes 2 and 3 with actual length.
3. After EOF: compute the next page, then rewrite descriptor bytes 0, 1 and 2
with the packed next_packet_ptr + packet_len. Byte 3 (status) stays 0x00.
4. Advance RWP, push to rx_wptr FIFO, pulse rx_irq.
"""
@@ -112,6 +145,10 @@ class RXFrameAssembler(Elaboratable):
data_ctr = Signal(12)
# Total length = data_ctr + 4
total_len = Signal(12)
# The page the NEXT frame will start at. Computed before the
# descriptor is written, because the descriptor carries it as
# next_packet_ptr -- libogc writes those 12 bits straight to RRP.
next_page = Signal(12)
# Descriptor base (rwp*256) — saved when frame starts
desc_base = Signal(16)
@@ -207,7 +244,7 @@ class RXFrameAssembler(Elaboratable):
# RWP advanced, but the GC's ethertype test read the wrong
# offset and silently dropped every frame -- "no ACK" from the
# host with no other sign of trouble. The matching
# descriptor bug (same cause, in WRITE_LEN_HI) showed up as a
# descriptor bug (same cause, in the length write) showed up as a
# descriptor of 00 00 00 01.
#
# The first byte is written twice as a consequence of
@@ -247,10 +284,10 @@ class RXFrameAssembler(Elaboratable):
# Compute pages used: ceil((data_ctr + 4) / 256)
# = (total_len + 255) >> 8 = total_len[11:8] + (total_len[7:0] != 0)
m.d.sync += pages_used.eq(total_len[8:12] + (total_len[:8] != 0))
m.next = "WRITE_LEN_HI"
m.next = "CALC_NEXT"
# ⚠️ Both length writes must advance on `valid & ready`, NOT on
# `ready` alone. `valid` is scheduled with m.d.sync and so is low
# ⚠️ All three descriptor writes must advance on `valid & ready`,
# NOT on `ready` alone. `valid` is scheduled with m.d.sync and so is low
# for the whole first cycle in the state, while `ready` is tested
# immediately -- and coming out of WAIT_LAST_WRITE `ready` is
# usually already high. Testing `ready` alone therefore fired both
@@ -264,32 +301,72 @@ class RXFrameAssembler(Elaboratable):
# happens to hold `ready` high on the entry cycle, which is why
# bring-up's 342-byte DHCP frame read a correct 00 00 01 5A and
# this went unnoticed.
with m.State("WRITE_LEN_HI"):
# Overwrite descriptor byte 2 with total_len[15:8]
m.d.sync += self.eth_wr_addr.eq(desc_base + 2)
m.d.sync += self.eth_wr_data.eq(total_len[8:12])
with m.State("CALC_NEXT"):
# The descriptor carries next_packet_ptr, so the next page must
# be known BEFORE the descriptor is written -- this state used
# to come after it (as ADVANCE_RWP).
# next_rwp = ((rwp - 1 + pages_used) % 15) + 1
next_rwp_raw = Signal(8)
m.d.comb += next_rwp_raw.eq(rwp + pages_used)
with m.If(next_rwp_raw > _RX_PAGE_LAST):
m.d.sync += next_page.eq(next_rwp_raw - _PAGES_TOTAL)
with m.Else():
m.d.sync += next_page.eq(next_rwp_raw)
m.next = "WRITE_D0"
with m.State("WRITE_D0"):
# Descriptor byte 0 = 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_LEN_LO"
m.next = "WRITE_D1"
with m.State("WRITE_LEN_LO"):
# Overwrite descriptor byte 3 with total_len[7:0]
with m.State("WRITE_D1"):
# Descriptor byte 1 = packet_len[3:0] << 4 | next_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"):
# Descriptor byte 2 = packet_len[11:4]. Byte 3 (status) keeps
# the placeholder 0x00, which libogc reads as "no error".
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"):
# Descriptor byte 3 = status = 0x00 ("no error": libogc tests
# it against 0x84).
#
# ⚠️ This MUST be written explicitly and cannot rely on the
# placeholder. WRITE_PLACEHOLDER issues four writes but the
# first lands twice (eth_wr_addr trails wr_addr by a cycle),
# so desc_base+3 is NEVER written. That was harmless while
# the length write covered byte 3; now byte 3 is the status
# and leaving it would hand libogc whatever the previous
# frame -- or power-on -- left in that SPRAM cell, and any
# frame landing on a stale 0x80 or 0x04 would be discarded as
# an error. The simulation model reports it as 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(total_len[:8])
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"):
# next_rwp = ((rwp - 1 + pages_used) % 15) + 1
next_rwp_raw = Signal(8)
m.d.comb += next_rwp_raw.eq(rwp + pages_used)
with m.If(next_rwp_raw > _RX_PAGE_LAST):
m.d.sync += rwp.eq(next_rwp_raw - _PAGES_TOTAL)
with m.Else():
m.d.sync += rwp.eq(next_rwp_raw)
m.d.sync += rwp.eq(next_page)
m.next = "PUSH_WPT"
with m.State("PUSH_WPT"):
@@ -465,6 +542,27 @@ if __name__ == "__main__":
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 libogc'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)]
@@ -479,8 +577,9 @@ if __name__ == "__main__":
await send_frame(f1)
check("T1 rwp advance", await wait_irq(), 2)
total1 = len(f1) + 4
check("T1 descriptor", descriptor(1),
[0x00, 0x00, (total1 >> 8) & 0xFF, total1 & 0xFF])
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
# placeholder write being dropped: with only three placeholder bytes
# written the body landed at base+3 and every frame was shifted one
@@ -500,8 +599,9 @@ if __name__ == "__main__":
await send_frame(f2)
rwp2 = await wait_irq()
total2 = len(f2) + 4
check("T2 descriptor (len > 255)", descriptor(2),
[0x00, 0x00, (total2 >> 8) & 0xFF, total2 & 0xFF])
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)
@@ -564,8 +664,11 @@ if __name__ == "__main__":
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),
[0x00, 0x00, (total5 >> 8) & 0xFF, total5 & 0xFF])
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)
@@ -586,8 +689,9 @@ if __name__ == "__main__":
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),
[0x00, 0x00, (total6 >> 8) & 0xFF, total6 & 0xFF])
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)