Fix command-space writes, and replay libogc's real init sequence
Second protocol mismatch found the same way as the descriptor: by disassembling libogc's own driver instead of trusting our notes. COMMAND SPACE IS A 2-BYTE HEADER + DATA PHASE, not a single 4-byte transfer. CLAUDE.md said [0x40|reg, 00, 00, data] and the decoder followed it, waiting for a fourth byte and consuming the DATA byte as header byte 2 -- so every command-space WRITE was silently dropped. libogc's __bba_init writes command register 2 (its interrupt mask) at both the start and the end of init, so none of that took effect. What it actually sends: EXI_Imm (len=2, write) -> 42 00 the whole header, 0x4000 | (reg << 8) EXI_Sync EXI_ImmEx(len=1, write) -> 00 the data Three bytes between CS fall and CS rise. The device-ID read survived the wrong decode only because SPIMode3Slave answers it on a fast path independent of this FSM -- which is precisely why no test caught it: the only command-space traffic under test bypassed the decoder being tested. CHDR_2/CHDR_3 are replaced by CMD_WRITE, which takes the data byte straight after header byte 1 and auto-increments the command register so a multi-byte command write behaves like the register-space path. cmd_wreg latches the target because the write applies a cycle later, by which time cmd_reg has moved on. bba_register_file T10 writes command register 2 and reads it back, and is mutation-tested: restoring the 4-byte decode fails it. Also replaces the GUESSED init sequence in CLAUDE.md with the real transaction list. The old one was wrong in three ways that matter: libogc READS the MAC out of PAR0-5 rather than writing one (so our reset value is what the console uses), IMR is 0xDF not 0x86, and it writes RWP (0x0016/17) and RHBP (0x001A/1B) which the register file does not accept. The latter is harmless today -- RHBP is 0x000F, matching our hardcoded 15-page ring -- but a driver configuring a smaller ring would be ignored while the assembler kept writing outside it. New gc_bba_test suite 7 "libogc init" replays the sequence on hardware: cmd-space write (2-byte header, read back), MAC readable (one 6-byte PAR read, Nintendo OUI), and init replay (all 30 writes in order, then NWAYS/HIPR/NCRA.SR must still read correctly -- the old guessed header format turned a NWAYS read into an NCRA reset, so a misdecoded write is a real risk). bba.c gains bba_cmd_write/bba_cmd_read in libogc's shape. 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:
@@ -256,6 +256,7 @@ class BBARegisterFile(Elaboratable):
|
||||
is_write = Signal()
|
||||
cmd_space = Signal() # this transaction is a command access
|
||||
cmd_reg = Signal(6)
|
||||
cmd_wreg = Signal(6) # target of a pending command-space write
|
||||
byte_ctr = Signal(3) # response-byte index (device-ID sequence)
|
||||
cmd_wr = Signal() # command-space write pending
|
||||
prefetched = Signal() # first response byte already pushed
|
||||
@@ -436,7 +437,25 @@ 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: [reg, 00, 00, data] in ONE 4-byte transfer ──
|
||||
# ── command space: a 2-BYTE header, then a data phase ─────────
|
||||
#
|
||||
# [0x40|reg, 0x00] + N data bytes written, or
|
||||
# [reg, 0x00] + N data bytes read.
|
||||
#
|
||||
# ⚠️ 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:
|
||||
#
|
||||
# EXI_Imm (len=2, write) -> 42 00 the whole header
|
||||
# EXI_Sync
|
||||
# EXI_ImmEx(len=1, write) -> 00 the data
|
||||
#
|
||||
# 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.
|
||||
with m.State("CHDR_1"):
|
||||
with m.If(self.rx_rdy):
|
||||
m.d.comb += self.rx_en.eq(1)
|
||||
@@ -446,24 +465,31 @@ class BBARegisterFile(Elaboratable):
|
||||
# 2-byte header has too few pops to drain.
|
||||
with m.If(~is_write & (cmd_reg == 0) & id_phase):
|
||||
m.next = "DEVID"
|
||||
with m.Elif(is_write):
|
||||
m.next = "CMD_WRITE"
|
||||
with m.Else():
|
||||
m.next = "CHDR_2"
|
||||
# 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: response lands in byte slot 3.
|
||||
# Ordinary command read: push during header byte 1 so the
|
||||
# byte-boundary pop presents it in data slot 0.
|
||||
m.d.comb += [self.tx_data.eq(rd_val), self.tx_en.eq(1)]
|
||||
m.d.sync += prefetched.eq(1)
|
||||
|
||||
with m.State("CHDR_2"):
|
||||
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.
|
||||
with m.If(self.rx_rdy):
|
||||
m.d.comb += self.rx_en.eq(1)
|
||||
m.next = "CHDR_3"
|
||||
|
||||
with m.State("CHDR_3"):
|
||||
with m.If(self.rx_rdy):
|
||||
m.d.comb += self.rx_en.eq(1)
|
||||
with m.If(is_write):
|
||||
m.d.sync += cmd_wr.eq(1) # applied below
|
||||
m.d.sync += cmd_wdata.eq(self.rx_data)
|
||||
m.d.sync += [cmd_wr.eq(1),
|
||||
cmd_wdata.eq(self.rx_data),
|
||||
cmd_wreg.eq(cmd_reg),
|
||||
cmd_reg.eq(cmd_reg + 1)]
|
||||
with m.If(~self.cs_active):
|
||||
m.next = "TRAILER"
|
||||
|
||||
with m.State("TRAILER"):
|
||||
@@ -659,10 +685,10 @@ class BBARegisterFile(Elaboratable):
|
||||
m.d.comb += [self.tx_data.eq(sp_hold), self.tx_en.eq(1)]
|
||||
m.d.sync += sp_pending.eq(0)
|
||||
|
||||
# ── Command-space write, applied the cycle after CHDR_3 ──────────
|
||||
# ── Command-space write, applied the cycle after the data byte ───
|
||||
with m.If(cmd_wr):
|
||||
m.d.sync += cmd_wr.eq(0)
|
||||
with m.Switch(cmd_reg):
|
||||
with m.Switch(cmd_wreg):
|
||||
with m.Case(0x02): m.d.sync += r_cmd_imr.eq(cmd_wdata)
|
||||
with m.Case(0x03): m.d.sync += r_cmd_ir.eq(r_cmd_ir & ~cmd_wdata)
|
||||
|
||||
@@ -807,6 +833,38 @@ if __name__ == "__main__":
|
||||
await ctx.tick("sync").repeat(6)
|
||||
return result
|
||||
|
||||
async def cmd_write(ctx, creg, data):
|
||||
"""Command-space write in libogc's REAL shape: a 2-byte header
|
||||
[0x40|reg, 0x00] then the data bytes -- three bytes total for a
|
||||
single-byte write, NOT a 4-byte transfer."""
|
||||
ctx.set(reg.cs_active, 1)
|
||||
await push_rx(ctx, 0x40 | (creg & 0x3F))
|
||||
await push_rx(ctx, 0x00)
|
||||
for b in data:
|
||||
await push_rx(ctx, b)
|
||||
ctx.set(reg.cs_active, 0)
|
||||
await ctx.tick("sync").repeat(6)
|
||||
|
||||
async def cmd_read(ctx, creg, length=1):
|
||||
"""Command-space read: 2-byte header, then clock `length` bytes."""
|
||||
tx_log.clear()
|
||||
ctx.set(reg.cs_active, 1)
|
||||
await push_rx(ctx, creg & 0x3F)
|
||||
# Idle while header byte 1 is "on the wire". The device must push its
|
||||
# response DURING that byte: the engine's byte-boundary pop for data
|
||||
# slot 0 happens at the end of byte 1, which is before the FSM has even
|
||||
# seen byte 1 (the rx CDC is ~125 ns). A response pushed in reaction
|
||||
# to consuming byte 1 would land in slot 1 instead, so pushing both
|
||||
# header bytes back-to-back here would not model the real transfer.
|
||||
for _ in range(4):
|
||||
sample_tx(ctx) # the push lands HERE; a bare tick loses it
|
||||
await ctx.tick("sync")
|
||||
await push_rx(ctx, 0x00)
|
||||
result = await collect_tx(ctx, length)
|
||||
ctx.set(reg.cs_active, 0)
|
||||
await ctx.tick("sync").repeat(6)
|
||||
return result
|
||||
|
||||
# SPRAM contents the streaming-read test reads back (byte i = 0xA0+i).
|
||||
spram_mem = {0x100 + i: (0xA0 + i) & 0xFF for i in range(64)}
|
||||
|
||||
@@ -1030,6 +1088,29 @@ if __name__ == "__main__":
|
||||
print(f"T9 second ST trigger queues: {bool(ctx.get(reg.tx_ctrl_r_rdy))} "
|
||||
f"(was {bool(tx_ctrl_before)})")
|
||||
|
||||
# ── T10: command-space write in libogc's real 3-byte shape ───────
|
||||
# The decoder used to expect [reg, 00, 00, data] and wait for a fourth
|
||||
# byte, consuming the DATA byte as header byte 2 -- so every
|
||||
# command-space write was silently DROPPED. libogc writes command
|
||||
# register 2 (its interrupt mask) at both the start and the end of
|
||||
# init, so a dropped write means that mask is never applied.
|
||||
#
|
||||
# Nothing caught this before because no test issued a command-space
|
||||
# write at all: the device-ID READ is answered by SPIMode3Slave's fast
|
||||
# path, independently of this FSM, so the only command-space traffic
|
||||
# under test bypassed the decoder being tested.
|
||||
await cmd_write(ctx, 0x02, [0xF8]) # libogc's IRMASKNONE
|
||||
got = await cmd_read(ctx, 0x02, 1)
|
||||
print(f"T10 cmd write reg2=0xF8 -> read back {[hex(x) for x in got]}")
|
||||
if got != [0xF8]:
|
||||
errors.append(f"T10 command-space write dropped: read back {got}")
|
||||
|
||||
await cmd_write(ctx, 0x02, [0x00]) # ... and IRMASKALL
|
||||
got2 = await cmd_read(ctx, 0x02, 1)
|
||||
print(f"T10b cmd write reg2=0x00 -> read back {[hex(x) for x in got2]}")
|
||||
if got2 != [0x00]:
|
||||
errors.append(f"T10b command-space rewrite failed: {got2}")
|
||||
|
||||
# NO T10 FOR THE LATE-RESPONSE LEAK -- and that is deliberate.
|
||||
#
|
||||
# The guard added to the response handler (outstanding != 0) and the
|
||||
|
||||
Reference in New Issue
Block a user