Support BOTH command-space forms: Swiss's 4-byte and libogc's 2-byte

Reading Swiss's own driver (cube/patches/bba/bba.c) shows the previous commit
fixed libogc and BROKE Swiss.  The two disagree, and both are real:

  Swiss   bba_cmd_out8: exi_imm_read_write(0x40<<24 | reg<<24 | val, 4)
          -> ONE 4-byte transfer [0x40|reg, 00, 00, val], and bba_cmd_in8
             returns the LOW byte, so a read response must be in BYTE 3.
  libogc2 bba_cmd_outsnosel: req = (reg<<8)|0x4000;
          EXI_ImmEx(&req, 2, WRITE); EXI_ImmEx(val, len, WRITE)
          -> THREE bytes, value in byte 2, and a read takes byte 2.
  stock libogc: identical to libogc2 (read out of libbba.a).

They share bytes 0 and 1 and differ only in how many bytes follow, so one
decoder serves both: take the LAST data byte before CS rise as the write value
(Swiss sends [00, val], libogc sends [val] -- last is val either way), and push
a read response TWICE so bytes 2 and 3 both carry it.  Swiss clocks 4 bytes and
pops both; libogc clocks 3 and leaves one, which the next transaction's header
pops drain -- a 2-byte header gives exactly one drain slot, so the leftover is
bounded at one and cannot accumulate.

Critically this means NO auto-increment of cmd_reg across the data phase: that
applied Swiss's padding 00 to the register and val to reg+1, which is precisely
how the libogc-only version broke Swiss.

T12 covers the Swiss form and asserts bytes 2 AND 3; T10 keeps the libogc form.
Both mutation-tested: re-adding the auto-increment fails T12 with 0x00 in both
slots, and pushing the response once fails T12's byte 3.  gc_bba_test gains
"cmd-space Swiss" alongside "cmd-space write", with bba_cmd_write4/read4.

Also corrects two things in CLAUDE.md now checkable against three drivers:
- NO driver writes TWD -- not Swiss, not libogc, not libogc2.  The "GC sets
  TWD before streaming" note was self-confirming: t_tx writes TWD because we
  wrote t_tx.  The counted TXDATA length is the primary path, not a fallback.
- Swiss's exi_dma_write rounds the length UP to 32 bytes (OSRoundUp32B), so
  TXDATA gets up to 31 trailing bytes past the frame and a counted length sends
  that much extra.  Real hardware must do the same since Swiss works on it;
  trailing padding past the IP total length is ignored.  A 1514-byte frame
  rounds to 1536 though, over the 1518 maximum.

Both drivers trigger TX with NCRA ST1, which the existing any-ST test accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 11:01:33 +02:00
parent 71ec6a2b37
commit b3685237b2
5 changed files with 223 additions and 52 deletions
+104 -32
View File
@@ -257,6 +257,8 @@ class BBARegisterFile(Elaboratable):
cmd_space = Signal() # this transaction is a command access
cmd_reg = Signal(6)
cmd_wreg = Signal(6) # target of a pending command-space write
cmd_have = Signal() # at least one data byte seen this txn
cmd_pushes = Signal(2) # command-read responses pushed
byte_ctr = Signal(3) # response-byte index (device-ID sequence)
cmd_wr = Signal() # command-space write pending
prefetched = Signal() # first response byte already pushed
@@ -378,7 +380,8 @@ class BBARegisterFile(Elaboratable):
# PREVIOUS transaction would be pushed as this
# one's first response and shift the whole
# read by one.
sp_pending.eq(0)]
sp_pending.eq(0),
cmd_have.eq(0), cmd_pushes.eq(0)]
with m.If(self.rx_data[7]): # register space
m.d.sync += cmd_space.eq(0)
m.next = "RHDR_HI"
@@ -448,25 +451,39 @@ class BBARegisterFile(Elaboratable):
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += [eff_addr.eq(eff_addr + 1), prefetched.eq(1)]
# ── command space: a 2-BYTE header, then a data phase ─────────
# ── command space: 2-byte header, then a data phase ───────────
#
# [0x40|reg, 0x00] + N data bytes written, or
# [reg, 0x00] + N data bytes read.
# ⚠️ THE TWO REAL DRIVERS DISAGREE ON THE LENGTH, and both must
# work. Checked against the source, not guessed:
#
# ⚠️ NOT a single 4-byte transfer. This file used to decode
# [reg, 00, 00, data] and wait for a fourth byte, taking the DATA
# byte as header byte 2 -- so every command-space WRITE was
# silently dropped. libogc's own driver (see the init map in
# CLAUDE.md) does, for a write of 0x00 to command register 2:
# Swiss (cube/patches/bba/bba.c, bba_cmd_out8 / bba_cmd_in8):
# exi_imm_read_write(0x40<<24 | reg<<24 | val, 4)
# -> ONE 4-byte transfer [0x40|reg, 00, 00, val], and a read
# takes the LOW byte of the 32-bit result, so the response
# must land in BYTE 3.
#
# EXI_Imm (len=2, write) -> 42 00 the whole header
# EXI_Sync
# EXI_ImmEx(len=1, write) -> 00 the data
# libogc (libbba.a, gcif.o, __bba_init):
# EXI_Imm (len=2, write) -> 42 00 the whole header
# EXI_ImmEx(len=1, write) -> 00 the data
# -> THREE bytes, data in byte 2, and a read takes byte 2.
#
# i.e. three bytes between CS fall and CS rise, not four. The
# device-ID read survived the old decode only because
# SPIMode3Slave answers it on the fast path, independently of
# this FSM, which is why nothing caught it.
# Both share bytes 0 and 1, so one decoder serves both:
#
# WRITE: latch every data byte and apply the LAST one at CS
# rise. Swiss sends [00, val] so the last is val;
# libogc sends [val] so the last is val. Hence NO
# auto-increment of cmd_reg -- that would apply Swiss's
# padding 00 to reg and val to reg+1.
# READ : push the response TWICE so byte 2 AND byte 3 carry it.
# Swiss clocks 4 bytes and pops both; libogc clocks 3 and
# leaves one, which the next transaction's header pops
# drain. A 2-byte header gives exactly one drain slot,
# so the leftover is bounded at one and cannot build up.
#
# This file previously had only the 4-byte form (Swiss's), and the
# decoder waited for a fourth byte -- so libogc's writes were
# dropped. Changing it to libogc's form alone then broke Swiss.
# Neither driver is "the" driver; support both.
with m.State("CHDR_1"):
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
@@ -479,29 +496,37 @@ class BBARegisterFile(Elaboratable):
with m.Elif(is_write):
m.next = "CMD_WRITE"
with m.Else():
# Read: the response was prefetched below and the pop
# at the end of this byte lands it in data slot 0.
m.next = "TRAILER"
with m.Elif(~is_write & self.tx_rdy & ~prefetched):
# Ordinary command read: push during header byte 1 so the
# byte-boundary pop presents it in data slot 0.
m.next = "CMD_READ"
with m.Elif(~is_write & self.tx_rdy & (cmd_pushes == 0)):
# Push #1, during header byte 1, so the byte-boundary pop
# presents it in byte 2 -- what libogc reads.
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += prefetched.eq(1)
m.d.sync += cmd_pushes.eq(1)
with m.State("CMD_READ"):
# Push #2 lands in byte 3 -- what Swiss reads. It has a whole
# byte-time to get there, unlike push #1.
with m.If(self.tx_rdy & (cmd_pushes < 2)):
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
m.d.sync += cmd_pushes.eq(cmd_pushes + 1)
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1) # Swiss's padding bytes
with m.If(~self.cs_active):
m.next = "HEADER0"
with m.State("CMD_WRITE"):
# Data phase. Each byte writes the running command register,
# which auto-increments so a multi-byte command write behaves
# like the register-space path. cmd_wreg latches the target
# separately because the handler below applies the write a
# cycle later, by which time cmd_reg has already moved on.
# Data phase. Latch each byte; the LAST one before CS rise is
# the value, which is what lets Swiss's 4-byte form and
# libogc's 3-byte form both decode correctly.
with m.If(self.rx_rdy):
m.d.comb += self.rx_en.eq(1)
m.d.sync += [cmd_wr.eq(1),
cmd_wdata.eq(self.rx_data),
m.d.sync += [cmd_wdata.eq(self.rx_data),
cmd_wreg.eq(cmd_reg),
cmd_reg.eq(cmd_reg + 1)]
cmd_have.eq(1)]
with m.If(~self.cs_active):
m.next = "TRAILER"
with m.If(cmd_have):
m.d.sync += cmd_wr.eq(1)
m.next = "HEADER0"
with m.State("TRAILER"):
# Soak up anything else the GC clocks, then wait for CS to rise.
@@ -882,6 +907,32 @@ if __name__ == "__main__":
await ctx.tick("sync").repeat(6)
return result
async def cmd_write_swiss(ctx, creg, val):
"""Swiss's command write: ONE 4-byte transfer
[0x40|reg, 0x00, 0x00, val] -- exi_imm_read_write(..., 4)."""
ctx.set(reg.cs_active, 1)
await push_rx(ctx, 0x40 | (creg & 0x3F))
await push_rx(ctx, 0x00)
await push_rx(ctx, 0x00) # padding, NOT the value
await push_rx(ctx, val) # the value is the LAST byte
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
async def cmd_read_swiss(ctx, creg):
"""Swiss's command read: a 4-byte read/write whose LOW byte (byte 3)
is the response. Returns (byte2, byte3)."""
tx_log.clear()
ctx.set(reg.cs_active, 1)
await push_rx(ctx, creg & 0x3F)
for _ in range(4):
sample_tx(ctx)
await ctx.tick("sync")
await push_rx(ctx, 0x00)
got = await collect_tx(ctx, 2)
ctx.set(reg.cs_active, 0)
await ctx.tick("sync").repeat(6)
return got
# SPRAM contents the streaming-read test reads back (byte i = 0xA0+i).
spram_mem = {0x100 + i: (0xA0 + i) & 0xFF for i in range(64)}
@@ -1146,6 +1197,27 @@ if __name__ == "__main__":
ctx.set(reg.tx_irq_i, 0)
await ctx.tick("sync").repeat(2)
# ── T12: Swiss's command-space form, which DIFFERS from libogc's ──
# Swiss sends ONE 4-byte transfer with the value as byte 3 and reads
# the response from byte 3; libogc sends three bytes with the value as
# byte 2 and reads byte 2. Both are real drivers, so both must work:
# taking the LAST data byte as the value serves both, and pushing the
# read response twice puts it in byte 2 AND byte 3.
#
# Supporting only one of them is not a theoretical risk -- this file
# had Swiss's form only (libogc's writes were dropped), then briefly
# had libogc's form only (Swiss's writes would have been dropped).
await cmd_write_swiss(ctx, 0x02, 0x5A)
both = await cmd_read_swiss(ctx, 0x02)
print(f"T12 Swiss 4-byte cmd write 0x5A -> bytes 2,3 = "
f"{[hex(x) for x in both]}")
if len(both) < 2 or both[1] != 0x5A:
errors.append(f"T12 Swiss cmd form: byte 3 = "
f"{[hex(x) for x in both]}, want 0x5A")
if len(both) >= 1 and both[0] != 0x5A:
errors.append(f"T12 libogc byte-2 slot also wrong: {hex(both[0])}")
await cmd_write_swiss(ctx, 0x02, 0x00) # restore
frame11 = [0x40 + i for i in range(24)]
await exi_write(ctx, _TXDATA, frame11) # the frame, in ONE write
await exi_write(ctx, _NCRA, [0x04]) # ST trigger, no TWD