Commit Graph

174 Commits

Author SHA1 Message Date
Roflin 41ba716759 spi_mode3_slave: document the engine, and fix three stale comments
418 -> 439 lines, 31% -> 14% comment-only.  The reasoning that matters here is
kept in full, because the edge conventions and the TX launch phase are the two
things in this design that cost silicon bugs; what went was the history around
them and the console measurement logs, which CLAUDE.md already holds.

Three comments described mechanisms that no longer exist:

  * `miso_next` was documented as feeding "an SB_IO output register clocked on
    the FALLING edge".  It is clocked on the RISING edge (NEG_TRIGGER=0) --
    that is the whole point of the full-cycle-early launch.  The file
    contradicted itself, since the comment on the assignment 100 lines later
    said "after the next RISING edge" correctly.
  * `id_arm` was documented as "the register file's id_phase, synchronised
    into this domain by the parent".  The one-shot was removed; id_arm is tied
    high and the register file has no id_phase.  It is now driven low only by
    the PHASE_METER build.
  * T8's rationale said the gate exists so that "once the GC's first NCRA
    reset clears id_phase, address 0 reads back NCRA".  That justification was
    not merely stale, it was WRONG -- NCRA is register space and the fast path
    requires command space, so they were never ambiguous.  T8's real job is to
    prove a disarmed build passes the FIFO byte through, which is what
    PHASE_METER depends on.

Structure: elaborate() now delegates to _rx_bytes, _device_id and _tx_bits,
each carrying the reasoning for its own block -- why rx_byte/rx_valid must be
combinational (a registered pulse loses the last byte of every transaction),
why the device ID is answered here rather than through the FIFOs, and the
output-path budget that forces the early launch.

VERIFIED EQUIVALENT: generated RTLIL is identical for BOTH build variants
(phase_meter False and True), 395 lines each, after stripping \src attributes.
Executable testbench code is unchanged apart from one trailing comment.  The
suite still has teeth: mutating the TX load back to rx_bit == 0 (a cycle late)
fails 8 of the 12 checks.  Full module suite passes and bba_top T9 is 6/6 at
EXI_HALF=1, 2 and 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:57:03 +02:00
Roflin b5961d0731 synth: document the build flow, and fix comments that had gone stale
589 -> 602 lines but 42% -> 13% comment-only: the prose shrank by two thirds
while the structure and the docstrings grew.  The module docstring now carries
the usage, both timing targets, the per-seed build layout and every environment
variable in one place.

SIX of the comments here were not merely verbose, they were WRONG, and three of
them described hardware behaviour that had been replaced:

  * MISO was described in three places as leaving on the FALLING edge with the
    path having to fit a HALF period (18.5 ns).  It has not worked that way
    since the full-cycle-early launch: NEG_TRIGGER=0 keeps the IO register on
    the RISING edge and the bit is launched one edge before the host samples
    it, so the budget is a FULL period.  The >12 ns warning text said the same
    wrong thing to anyone who tripped it.
  * The MISO clock-to-pad parse had no note saying it is EXPECTED to find
    nothing.  "IOB (none)" is the good case -- a number there means the output
    has regressed into the fabric -- and only the summary printer said so.
  * Two comments sat adjacent at the ranking site giving DIFFERENT rules: one
    said rank by worst relative fmax margin, the next said rank by smallest
    MISO delay.  The code does MISO first and falls back to margin; the
    docstring now says that once.
  * "~22% LC utilisation" -- it is ~81%, which inverts the point being made
    (routing dominates because the part is FULL, not because it is empty).
  * "the final artefact in build/top.bin is the result of the last (or best)
    seed" was contradicted 150 lines later by "There is no top-level
    build/top.bin".  The latter is true.
  * A comment described `results` as 4-tuples; the code appends 5.

Structure: the 240-line __main__ is now main() over _udp_config,
_make_dut_factory, _build_seed, _parse_timing, _score, _run_sweep and
_print_summary.  elaborate() delegates to _drive_miso and
_drive_status_panel.  Each keeps the reasoning that belongs to it -- why a
fresh platform and a separate build dir per seed (both were real mis-scoring
bugs), why the post-route number is the last occurrence, why an unparseable
seed must never be ranked.

Cut: the pin-35/PLL history, which ends in a conclusion that is all that
matters now (ETH_INT is on pin 13 because the board is built that way); the
reverted router-options experiment, down to one line; and a duplicated
paragraph about the clock constraint, plus a duplicate `import os`.

VERIFIED EQUIVALENT, not just tested:
  * Generated RTLIL is identical across all 30659 lines after stripping the
    \src source-location attributes -- those 44 lines are the only diff, so
    the hardware is provably unchanged.
  * The ranking logic was differential-tested against the old algorithm
    transcribed verbatim: 4000 randomised sweeps including unparseable seeds
    and the best_miso/best_score interaction, 0 mismatches.
  * A real build of seed 1 reports clk 27.62 / exi 59.47 MHz, matching the
    pre-refactor sweep's seed 1 exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:48:49 +02:00
Roflin f4e7e2c661 CLAUDE.md: refresh the seed table after the cleanup pass
Best seed is 3, not 6, and the flash skill points at build/seed<N>/top.bin --
so a stale seed number sends you to the wrong bitstream.  Also records the
area, which is the thing the cleanup actually moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:42:19 +02:00
Roflin 41d487e1a7 Cut dead signals and the unused W5100 debug peek
An audit for signals that are written but never read turned up three things no
longer connected to anything.  All verified unreferenced across every module,
testbench and script before removal.

  exi_capture      cs_n_prev          registered on sync, read by nothing.
  w5100            udp_rx_sof         driven in the UDP rx stream mux; no
                                      consumer uses it.  uart_shell needs only
                                      data/valid/eof, because the request
                                      frames the transfer.  The MACRAW rx
                                      interface keeps its sof -- the frame
                                      assembler does need it.
  w5100            the debug peek      4 ports, 2 FSM states (DBG_RD/DBG_RD_W)
                                      and a branch in the IDLE arbiter's
                                      priority chain.  dbg_rd_req was driven by
                                      nothing -- not even its own testbench --
                                      so this was untested logic sitting in the
                                      path that decides what the bus master
                                      does next.

Kept deliberately, against the audit: the unused register-address constants in
both ethernet masters (_IR, _TMSR, _S0_SR, _S0_TX_FSR, _S0_TX_RD and the three
W5500 buffer ones).  They cost no logic and they document the chip's register
map in one place; that map was the source of three hardware bugs, and a partial
map invites re-guessing the missing half.  Same for status_panel's eth_rst_n,
which is a documented panel feature synth.py has not wired yet -- a gap, not
dead weight.  And rx_frame_assembler's geom_honoured, which is one comb line
asserting a property the tests otherwise could not observe.

Also fixed the register file's opening docstring, which claimed the module did
"three jobs" and invited splitting it into three.  It does not: the interrupt
level is IR & IMR, a frame arriving is a register side effect, and the read mux
feeds the TX stream combinationally in the cycle the FSM pushes it.  Those are
facets of one job that share the register bank, not separable parts.  _read_mux
now records why that path must stay unregistered -- one pipeline register there
is 42 ns against a 296 ns byte budget, which is the tx FIFO underrun that the
+1 descriptor hunt ended in.

Full suite passes; bba_top T9 6/6 at EXI_HALF=1, 2 and 8; W5100 elaborates with
enable_udp_test both ways.  Sweep: 8/8 seeds PASS, best seed 3 at clk 29.08 MHz
(1.21x the 24 MHz target), and area down again:

    ICESTORM_LC   4350 original -> 4328 after the rx_rptr cut -> 4305 now

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:42:02 +02:00
Roflin dac9c0c065 bba_register_file: delete the dead rx_rptr FIFO and unused constants
An audit for signals that are written but never read, ports nothing wires, and
constants only the tests touch turned up something bigger than stray signals:
the whole rx_rptr SyncFIFO was dead.

asm.rrp is wired from reg.rrp -- the combinational tap -- and the FIFO's far
side (rx_rptr_r_data / _r_en / _r_rdy) is wired NOWHERE in BBATop.  It is a
leftover from when the register file and the frame assembler sat in different
clock domains; when those merged the direct tap replaced it and the FIFO was
never removed.  Gone: a 4x8 SyncFIFO, its write logic in the RRP case, its
default strobe and three ports.

Also removed _RWP_HI and _RRP_HI, which were never referenced.  The register
table now says why instead: the ring is 15 pages, so the high bytes of both
pointers are always zero and the read mux's default already returns that.

NCRB_PR went the other way -- it was used only by a test while BBATop steered
the ethernet address filter from a bare `reg.ncrb[0]`.  It is now public and
BBATop imports it, so the constant is live and the magic index is gone.

Kept deliberately: _NCRB_PM and _NCRB_AB are referenced only by T15, but they
document the filtering bits of a real register and make that assertion
readable, so they stay.

Area went DOWN, which is the evidence the FIFO was real logic rather than
something yosys had already pruned:

    ICESTORM_LC   4350 original -> 4380 after the docs pass -> 4328 now

Full suite passes, bba_top passes at EXI_HALF=1/2/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:25:51 +02:00
Roflin b175cb0e77 bba_register_file: document the gateware, drop the history and client talk
First file of the documentation cleanup.  Comments 403 -> 83 lines (46% ->
12% of the module), total 1563 -> 1266, with the real documentation moved into
structured docstrings instead of inline prose.

What went:
  * every account of how a bug was found, what it looked like on a console,
    and which fix attempts were falsified -- all of that lives in CLAUDE.md
  * every description of how Swiss or libogc is written internally; the file
    now documents what the GATEWAY does and what a host observes, not how any
    particular host is implemented.  Zero occurrences of Swiss/libogc/gcif
    remain, in comments, docstrings, helper names or test output
  * the module docstring, which still described the GUESSED header format
    (addr[12:6] plus a 2-bit length field) replaced long ago

What arrived:
  * a register table giving each address its name AND its purpose, bit
    meanings included (NCRA reset/ST0/ST1/SR, IR's RI/TI/RBFI, the page
    pointers, which side owns RWP vs RRP)
  * a command-register section, likewise
  * named IR/IMR and NCRB bit constants instead of bare indices
  * a class docstring grouping the ports into EXI stream / ethernet side /
    register taps / events
  * an FSM docstring listing all 13 states in visit order with what each one
    is for, and the invariant that every data phase ends on ~cs_active

Structure: elaborate() is now a table of contents -- _make_fifos,
_make_registers, _register_taps, _interrupts, _read_mux, _transaction_fsm,
_spram_responses, _command_write, _ethernet_events -- with register storage,
transaction state and the FIFOs passed as three namespaces so each block's
inputs are visible.  The register write decode is its own method rather than
80 lines nested inside a state.

Dead code removed: sp_req, byte_ctr, drain_ctr, SP_LIMIT and _DEVICE_ID were
all write-only or declaration-only.

Behaviour preserved, checked four ways: all 15 module tests pass, the full
module suite passes, bba_top passes at EXI_HALF=1/2/8, and the hardware is
materially identical -- flip-flops exactly equal (1177 SB_DFFE, 671 SB_CARRY),
RAM/IO/global buffers unchanged, ICESTORM_LC 4350 -> 4380 (+0.7%).  That rise
is the read mux: its Switch cases are now in address order, which changes how
the mux tree maps.  All cases are distinct constants so the decode is
semantically identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:18:38 +02:00
Roflin c887ab32fa fsp-server: report capability separately from duty cycle
The rate line was bytes/elapsed, which is not the capability of the path: the
game reads in bursts, so any window containing idle time understates what the
link can do.  Dennis spotted this -- 593 KB/s observed while the per-round-trip
arithmetic says ~700 -- and the fix belongs in the tool, not in the reader's
head.

Now reports, every two seconds:

  average   bytes / elapsed, including idle   (what the game happened to want)
  CAPABLE   bytes / time ACTUALLY serving     (what the path can do)
  duty      fraction of the window streaming
  round trip median gap, split into total and this server's service time,
            plus the implied ceiling (reply_size / RTT) at that round trip

Consecutive requests closer together than 10 ms count as streaming (the round
trip is ~2 ms); longer gaps are the game not asking and are excluded from the
capability figure.  A synthetic burst model confirms the split: 1440 B every
2 ms in bursts with 200 ms idle reports 352 KB/s average but 703 KB/s capable
at 50% duty, which is the distinction that matters when asking whether the
adapter is the limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:45:14 +02:00
Roflin 4e412c8b9b fsp-server: stop clamping GET_FILE replies to 1024, which capped streaming
Streaming measured 509 KB/s, BELOW the menu's 850 KB/s TCP dump, and the
arithmetic says exactly why.

I had this wrong: QUEUE_SIZE 2 is the number of queued GAME reads, not
concurrent network requests.  fsp_read_queued() sends ONE fsp_get_file and
arms a 1 s alarm; fsp_pop_queue() issues the next only once the response
arrives.  Streaming is therefore STRICTLY ONE REQUEST IN FLIGHT, so

    throughput = bytes_per_reply / round_trip

The server clamped replies to FSP_SPACE (1024), giving 1024 B / ~2.01 ms =
509 KB/s -- the measured figure almost exactly.  But the in-game client asks
for pmtu - 40 (1460 at a 1500 MTU) and accepts it; only fsplib has the 1024
limit, and fsplib never asks for more than that.  So honouring the requested
size is safe for both clients, and at 1440 B (the 1500 MTU budget rounded
down to 32) the same round trip gives ~1.41x, about 700 KB/s.

Replies stay a multiple of 32 unless they are the file tail, because the
client computes data_size = MIN(requested, OSRoundDown32B(reply_len)) and a
non-aligned mid-file reply would be silently truncated.

Also instruments the round trip: with one request in flight the observed
interval IS the RTT, so the report now splits it into total and this server's
median service time and says SERVER-bound or console-bound.  Without that,
"it is slow" has no owner.  And the raw socket now binds ETH_P_IP rather than
ETH_P_ALL, so our own outgoing frames are no longer copied to userspace and
parsed in Python on the path we are trying to shorten.

T13 checks a 1460-byte request yields 1440 (32-aligned) while an fsplib-sized
1024 request still yields exactly 1024.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:39:46 +02:00
Roflin 2172a85f0e fsp-server: implement writes, which booting a game requires
Swiss reported "failed to write file" then "Failed to setup the file (too
fragmented?)".  The FSP device declares FEAT_READ|FEAT_WRITE|FEAT_BOOT_GCM|
FEAT_PATCHES, so booting a disc image from FSP is supported -- but Swiss
writes patch files to swiss/patches/ first, and deviceHandler_FSP_setupFile
returns failure when that write fails, which the UI reports as the fragment
message.

Implements CC_UP_LOAD (0x43), CC_INSTALL (0x44), CC_MAKE_DIR (0x49),
CC_DEL_FILE (0x45) and CC_DEL_DIR (0x46), all confined to --root.  --writable
(aliased as --claim-owner) enables them and reports FSP_DIR_OWNER, so the two
can no longer be set inconsistently: previously --claim-owner told Swiss it
could write to a server with no write support, which is exactly the
combination that produced the error.

⚠️ CC_UP_LOAD CARRIES NO FILENAME.  fsplib calls buildfilename() only on the
read branch, so the data field is RAW FILE BYTES at `pos` and CC_INSTALL names
the destination afterwards (temp-then-commit).  Handling UP_LOAD after the
path/password parse rejects every chunk as a bad password, so it is handled
before it; the password is validated on INSTALL, which does carry the path.
`pos` must also be echoed, because fsp_transaction checks it for UP_LOAD.

Tests: MAKE_DIR creates the directory, two 1024-byte UP_LOADs plus INSTALL
commit byte-exact content, writes outside --root are refused, and GET_PRO
reports OWNER when writable.  Mutation-tested: moving UP_LOAD back below the
password parse fails T11 with CC_ERR on every chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:32:36 +02:00
Roflin 7b94da0be5 fsp-server: add a raw-ethernet transport, because UDP replies never arrived
The trace settled it: our reply satisfies EVERY check fsplib makes --
fsp_pkt_read's checksum (0x9a computed, 0x9a present), len <= recv_len,
(reply.seq & 0xfff8) == s->seq, cmd match, and fsp_getpro's pos ==
FSP_PRO_BYTES with the flags byte at buf[len].  Yet the console retried the
same transaction (seq 0x8ccb -> 0x8ccc, txn 0x8cc8 try 3 -> try 4).

The decisive evidence is the key: fsplib calls client_set_key(rpkt->key) on any
ACCEPTED reply, so the next request would carry our 0x1234.  Every request
still carries 0x8022.  No reply has ever been accepted -- so they are not
arriving, and the problem was never the protocol.

What differs from the thing that worked: mini-dhcpd.py replies over AF_PACKET
and got a DHCP lease immediately, while this server used a normal UDP socket,
whose reply depends on the kernel having a route to the console, an ARP entry
for it, and choosing the right source address.  (AF_PACKET also never populates
the kernel ARP table, so DHCP succeeding tells us nothing about those.)

--iface <dev> now replies with raw ethernet frames that MIRROR the request's
own addresses -- MACs swapped, IPs swapped, ports swapped -- which removes
routing, ARP and source-address selection in one go.  The UDP path is kept for
when it is not needed.

Frame construction is tested: the reply carries the console's MAC as
destination and ours as source, source IP 10.0.0.1 and destination 10.0.0.64
taken from the request, ports 21 -> 4096, a valid IP checksum, correct UDP
length, and padding to the 60-byte ethernet minimum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:26:00 +02:00
Roflin e56f21341a fsp-server: log the transaction id and retry counter, add --trace
Swiss is still stuck on "setting up device" and the log shows five identical
GET_PRO lines -- but there was no way to tell whether that is ONE transaction
being retried (our reply rejected or lost) or five separate transactions
(Swiss looping at a higher level).  Those have completely different causes and
the log could not distinguish them.

fsp_transaction puts a random value in seq & 0xfff8 per transaction and the
retry counter in seq & 0x7:

    p->seq = (s->seq) | (retry & 0x7);

so the split is the diagnostic.  Every request now logs
seq=0x.... [txn 0x.... try N]: same txn with climbing try means our reply is
being rejected; different txn each time means the client gave up and restarted.

--trace additionally hexdumps every request and reply with key/pos/len/xlen,
so a reply can be verified by hand against fsp_pkt_read instead of trusted.

All replies now go through one reply() helper rather than scattered sendto
calls, which is what makes tracing them possible at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:20:23 +02:00
Roflin cb0df55699 fsp-server: reply checksums must use fsplib's VERIFIER formula, not its writer
Swiss hung on "setting up device" while the server logged perfectly reasonable
GET_PRO replies.  fsplib was dropping every one and retrying -- visible in the
log as the same request repeating with the low 3 sequence bits counting up,
since fsp_transaction encodes the retry number there.

FSP has TWO checksum formulas and they differ by the packet length:

  fsp_pkt_write (what a client SENDS):
      checksum = sum(bytes, sum byte zeroed) + used
      sum_byte = checksum + (checksum >> 8)

  fsp_pkt_read (what a client ACCEPTS):
      mysum = sum(bytes) - sum_byte           <- no length term at all
      ok if ((mysum + (mysum >> 8)) & 0xff) == sum_byte

cube/patches/bba/tcpip.c matches the WRITE form (`sum = size` to start) and
never verifies replies, so the in-game path tolerated our packets; the menu's
fsplib did not.  Replies are now built the way fsp_pkt_read verifies.
Incoming requests are accepted under either formula, since a tolerated request
is far easier to diagnose than a rejected one.

⚠️ The self-test passed throughout the bug, and that is the part worth
remembering: it verified replies with the SERVER'S OWN checksum function, so
the two agreed with each other and disagreed with Swiss.  Identical in shape to
the RX descriptor format, where our writer, our readers and our tests were all
self-consistent and all wrong.  The test now transcribes fsp_pkt_read literally
as fsplib_verify() and checks every reply against that, and mutating the server
back to the with-length formula fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:16:56 +02:00
Roflin f3fee56673 fsp-server: implement GET_PRO (0x47), which Swiss asks for first
Swiss sent CC_GET_PRO 'swiss/patches' and the server replied "unsupported".
Reply shape, from fsplib's fsp_getpro:

  * pos MUST equal FSP_PRO_BYTES (1), or fsplib fails with ENOMSG
  * the flags byte is at buf[len] -- the first byte of the EXTRA region,
    after the echoed name, NOT inside the data field
  * a missing directory must be CC_ERR so fsplib reports ENOENT, because
    Swiss probes "swiss/patches", then "swiss", then "" and relies on that
    fallback chain

Those bits are not cosmetic.  deviceHandler-FSP.c does:

  if ((pro & (MKDIR|ADD|DEL|GET)) == (MKDIR|ADD|DEL) || (pro & OWNER)) {
      features |= FEAT_PATCHES;  emulable |= EMU_ETHERNET;
  } else { ... clears both ... }

so a plain read-only answer makes Swiss DISABLE patch support on this device.
Default is therefore the honest GET|LIST (0x50) with patches disabled, and
--claim-owner reports OWNER to turn them back on -- flagged in the help as
unsafe for now, because this server has no UP_LOAD or MAKE_DIR, so Swiss
believing it can write here would make a patch write fail rather than degrade.
Try without it first.

T9 checks pos == 1 and that the flags land at buf[len] with GET|LIST and
without OWNER; T10 checks a missing directory gives CC_ERR so the fallback
chain works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:11:34 +02:00
Roflin fd95643593 scripts: add a read-only FSP server so Swiss can stream a disc image
sudo ./scripts/fsp-server.py --root /workspace --password alltheroms

Swiss streams games over FSP/UDP rather than TCP, so hosting an image needs an
FSP server.  Serves a directory read-only: VERSION, STAT, GET_DIR, GET_FILE,
BYE, with CC_ERR for anything else.  Reports the achieved read rate every two
seconds, which is the measurement this was really built for -- the 850 KB/s
figure came from the menu's lwIP/TCP path and says nothing about streaming.

The protocol was taken from Swiss's own two clients rather than from memory,
because they disagree about nothing but exercise different commands:
  cube/swiss/source/devices/fsp/fsplib.c  (menu: browse, stat, read)
  cube/patches/bba/tcpip.c                (in-game streaming reads)

Details that are easy to get wrong, all verified against that source:
  * checksum: sum = total_size + every byte with the checksum byte zeroed,
    then sum += sum >> 8, low 8 bits.
  * password: the data field is "path\npassword\0" -- the '\n' REPLACES the
    path's terminating NUL (fsplib buildfilename), it is not appended after it.
  * GET_DIR: entries are time(4) size(4) type(1) name(NUL) padded to 4 bytes;
    the client reads blocks until one is shorter than the first, so the whole
    listing then an EMPTY reply is what terminates it.  dirpos % 4 != 0 is
    rejected outright by fsplib, hence the alignment padding.
  * STAT: the same 9-byte time/size/type prefix; type 0 means ENOENT.
  * GET_FILE: sequence AND position must be echoed -- the in-game client
    discards any reply where either differs.
  * ⚠️ a GET_FILE reply must be a multiple of 32 bytes unless it is the tail,
    because the in-game client does
        data_size = MIN(requested, OSRoundDown32B(reply_len))
    so a non-32-aligned reply is silently rounded DOWN and the stream stalls.

test-fsp-server.py runs a real server on 127.0.0.1 and drives it with a client
that packs requests exactly as Swiss does: checksums verify both ways, STAT
returns the true size, GET_DIR parses with fsplib's own walk and the second
page is empty, GET_FILE returns byte-exact data at three offsets with seq and
pos echoed, a 1024-byte request comes back 32-aligned, a wrong password gives
CC_ERR, and ../../etc/passwd is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 16:06:08 +02:00
Roflin 2da721d7ed scripts: add a GameCube disc-image validator for BBA dumps
./scripts/check-gcm.py dvd.iso

Checks the header (DVD magic, game/maker code, title, DOL/FST offsets,
apploader), parses the FST, then READS EVERY FILE -- because a dump that lost
data still has a perfect header, so structure alone proves nothing.  Also
reports how much of the image the filesystem accounts for, which is where a
truncated or zero-filled tail shows up, and prints SHA-1 for comparison
against a reference or a second dump.

⚠️ The zero-run heuristic was wrong in its first form and the fix is the
interesting part.  Flagging any zero run >= 32 KiB as a dropped transfer gave
7 false positives on a known-good Wind Waker dump, all in
Audiores/Stream/*.afc: streamed audio legitimately begins with silence right
after its 16-byte header.

What a dropped transfer actually looks like is a run ALIGNED to the transfer
granularity -- whole 2048-byte disc sectors, because nothing reads or writes a
fraction of a sector.  Structural silence starts and ends wherever the audio
does.  So alignment is the discriminator, not length.  Sector-aligned runs are
now a failure; unaligned ones are reported as structural with that reasoning
spelled out.

.gitignore: *.iso and *.gcm, so a 1.4 GB dump sitting in the workspace cannot
be committed by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:49:05 +02:00
Roflin 4e7a2ef429 analyse-pcap: count UDP, because ROM streaming is UDP/FSP not TCP
The 850 KB/s dump measurement does not bound ROM streaming, and the tool could
not have measured streaming at all: it only counted TCP payload, so a
streaming capture would have reported ~zero throughput.

Swiss's ROM streaming runs in the game via the patch driver's own stack
(cube/patches/bba/tcpip.c), which contains no TCP whatsoever -- UDP + FSP,
plus ICMP and ARP.  Reads are sized to the path MTU rather than 1024:

  _fsp.data_length = MIN(length, env->pmtu - (fsp->data - eth->data));

so ~1446 B of payload per datagram at a 1500 MTU, with QUEUE_SIZE 2
outstanding and udp->checksum = 0x0000 (no payload checksum).  None of the
menu path's TCP window, lwIP overhead or 1024-byte writes apply.

The analyser now counts UDP payload and picks the bulk direction by which way
the payload actually flows -- streaming is PC -> GC, the opposite of a disc
dump -- with --bulk to_gc to steer the gap analysis.  Self-test H replays the
streaming shape (1446 B datagrams, 2 per request) and checks the bulk
direction is identified and the payload counted.

Recorded in the TODO that "can it stream?" is still OPEN, but open on the GC's
side: the dump established the adapter has headroom (zero retransmissions,
~17% W5100 bus utilisation, 26% of the EXI ceiling).  The hard ceiling is
3375 KB/s and the GC's disc delivers ~2000-3100 KB/s, so full disc rate has no
margin -- but games read in bursts, not continuously at peak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:32:45 +02:00
Roflin d8ddea13ba Measure the throughput limit: window x RTT, not the adapter
Swiss dumping a disc over the adapter measures 850 KB/s (6.97 Mbit/s, 25.8% of
the 27 Mbit/s EXI ceiling).  The capture decomposes it completely:

  18395 data frames / 9200 ACKs = 2.0 frames per ACK
  18.84 MB / 9200             = 2048 B per round trip
  22.78 s / 9200              = 2.48 ms per round trip
  2048 B / 2.48 ms            = 826 KB/s  ~= the measured 850

So the flow is window x RTT bound and nothing else.  Payload per frame is
exactly 1024 B (wire 1078 minus 54 of headers) against a 2920 B window, so two
segments fit in flight.  Zero retransmissions means the 15-page ring never
overflowed; 0.2% stall time means nothing waits on the disc; our W5100 bus
engine sits at ~17% utilisation.  The adapter is not the constraint.

Levers, and the first two are not ours: a larger lwIP TCP_SND_BUF (scales
almost linearly while window-bound), and full-MSS segments -- Swiss writes
1024 B at a time, so the same window and RTT at 1460 B would give ~1150 KB/s,
a 1.43x gain for free.  Third is a shorter round trip, which is GC-side
interrupt dispatch plus lwIP, plus EXI lock contention: libogc2's
bba_event_handler defers when EXI_Lock fails, so RX queues behind TX on the
single channel.

⚠️ This REVERSES an earlier recommendation in the TODO.  RX interrupt
coalescing (NCRB.RXINTC, RXINTT) was listed as a throughput lever; for a
window-bound flow it makes things worse, because delaying the ACK's interrupt
lengthens the round trip and throughput is bytes-per-round-trip / RTT.  The
measurement showed the guess was backwards.  Noted in item 3 as "implement for
fidelity, not for speed".

analyse-pcap.py now derives frames-per-ACK, bytes-per-round-trip and the
implied RTT itself, identifies a window-bound flow, quantifies the full-MSS
gain, and warns against coalescing -- so the next person gets the conclusion
rather than the raw numbers.  Self-test G replays this exact shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:26:39 +02:00
Roflin d6fb7c81b9 scripts: add a pcap analyser to find the BBA throughput limit
Swiss dumps a disc over the adapter at 740-780 KB/s, which is ~23% of the
27 Mbit/s EXI ceiling (3375 KB/s), and no published real-BBA figure exists to
compare against.  Rather than reason about where the rest goes, measure it.

./scripts/analyse-pcap.py /tmp/bba.pcap --gc-ip 10.0.0.64 reports, per
direction: throughput as a fraction of the EXI ceiling, MEAN FRAME SIZE,
retransmissions, the GC's advertised TCP window, zero-window spans, and the
largest gaps between data frames.  Each one discriminates a different
bottleneck:

  mean frame far below 1514  -> per-frame overhead (interrupts, EXI
                                transactions, socket bookkeeping) dominates
  retransmissions            -> we are dropping frames, e.g. the 15-page RX
                                ring overflowing (RBFI)
  small advertised window    -> capped at window/RTT regardless of the wire
  zero-window spans          -> the GC cannot drain: CPU/stack bound
  large gaps, little sending -> the limit is upstream of the wire entirely
                                (disc read, CPU, stack), not the adapter

Classic pcap only, stdlib only, no root.  pcapng is detected and refused with
a conversion hint rather than misparsed.

scripts/test-analyse-pcap.py synthesises captures with known properties and
checks each conclusion is reached: 1000 full frames in 2 s reports ~22% of the
ceiling, 200-byte payloads raise the overhead warning, a repeated seq counts 9
retransmissions, one 300 ms hole in 100 frames is found and attributed, a
zero-window span is called CPU/stack bound, and a pcapng header is rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:21:47 +02:00
Roflin 0c5a7bf9db mini-dhcpd: stop reporting ARP as a corrupted frame
Swiss got a DHCP lease on hardware (10.0.0.64/24), but the script flagged
every ARP from the GC as "frame did NOT parse as DHCP ... this is what a
corrupted TX frame looks like".  That was wrong and actively misleading: the
frames are valid lwIP duplicate-address probes (sender 0.0.0.0, target =
the offered address), sent to check the lease is free before accepting it.

The parser conflated "not DHCP" with "corrupted".  A diagnostic that cries
wolf on normal traffic is worse than none, because 'malformed' then means
nothing.  Now:

  'info'      from the client but not DHCP -- ARP decoded properly (probe vs
              request vs reply, with addresses), other ethertypes named
  'malformed' CLAIMED IPv4/UDP->67 and then failed to parse, i.e. what a
              genuinely damaged TX frame would look like

So the corruption diagnostic still works -- that was the point of using a raw
socket -- but it only fires when it means something.

scripts/test-mini-dhcpd.py keeps the self-test, now including the exact
60-byte frame from the console (T6), a damaged DHCP packet that must still be
flagged (T7), and a raw ethertype (T8).

One note for anyone extending that test: the real frame goes in as ONE string
literal.  Splitting the hex across two adjacent literals is how the first
version of T6 silently lost a byte and blamed the parser for decoding the
target as 0.10.0.0 -- the parser was correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:07:44 +02:00
Roflin 29ce281a7f scripts: add a minimal raw-socket DHCP server for BBA bring-up
sudo ./scripts/mini-dhcpd.py enp0s31f6

Serves one address, logs every exchange, and does not touch the rest of the
system.  Needed because the Swiss DHCP timeout proves nothing while there is no
server on the segment to answer.

Deliberately AF_PACKET rather than a UDP socket on port 67.  A UDP socket only
sees frames the kernel accepts as well-formed IP/UDP, and one of the open
questions is whether the GC's frames arrive INTACT: libogc streams TX payload
with EXI_Dma and the GC's DMA-write engine is documented to have a 1-bit-shift
bug.  A corrupted DISCOVER would never reach a UDP socket and the server would
look "fine, just no client".  On a raw socket every frame from the adapter is
parsed by hand and anything that fails is reported as MALFORMED with the reason
and a hexdump -- which is exactly the evidence that would confirm or kill the
corruption hypothesis.

Replies go out as raw ethernet UNICAST to the client's MAC, so a successful
lease also demonstrates the new MACRAW MF filter accepts unicast to SHAR and
not just broadcast.

Self-tested without a network: a well-formed DISCOVER parses with the right
chaddr/xid, a corrupted magic cookie is reported as malformed rather than
ignored, frames from other MACs are skipped, and the generated OFFER is a valid
342-byte frame -- unicast to the client, correct IP checksum and total length,
ports 67->68, BOOTP padded to the 300-byte minimum, yiaddr as configured.
On-wire behaviour is NOT tested here; the devcontainer has only a veth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:02:34 +02:00
Roflin 2893e7f6fb Filter RX to own-MAC + broadcast: unfiltered MACRAW hung Swiss
Compat TODO item 1.  S0_MR now sets the MACRAW MF bit, so the W5100 delivers
frames addressed to SHAR or broadcast only -- what the real BBA does, since
libogc2 configures NCRB = AB|CA|2_PACKETS_PER_INT (PR=0, PM=0, AB=1).
NCRB.PR (bit 0) clears MF if the GC asks for promiscuous, sampled when socket 0
is OPENed, so a later PR change applies on the next NCRA reset.

This was not a performance issue, it was a HANG.  Swiss sat on "Initialising
Network" forever.  libogc2's bba_interrupt() loops

  while (status) { clear IR; start_rx; status = IR & IMR; }

which only exits when IR & IMR reaches zero.  Unfiltered, every frame on the
segment set RI, re-asserting faster than the loop could clear it, so it never
exited.  Unplugging the ethernet cable let Swiss run all the way through and
then fail DHCP for want of a server -- a one-minute experiment that identified
it after a lot of theorising about descriptors and interrupts.

Filtering against SHAR turned out to be safe for this path after all.  The
earlier worry was that MF filters on SHAR, which we write only at init, so a
driver WRITING PAR0-5 would leave it stale and MF would drop everything.
libogc2 READS the MAC out of PAR0-5 instead, so SHAR -- written from reg.par in
that same init -- cannot go stale underneath it.  Broadcast still passes MF and
a DHCP OFFER unicast to our MAC passes too.

Left open and recorded: refreshing SHAR on a PAR0-5 write plus a socket
re-open, for a driver that does write it (the official SDK driver is unread).
The W5500 back-end is unchanged, being the unused alternate path; it would need
the same Sn_MR treatment if revived.

T1 expects S0_MR = MACRAW|MF in the init sequence, T1b checks promisc high
re-inits with MF clear.  Mutation-tested: never setting MF fails both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:51:17 +02:00
Roflin 90a583dd46 Remove the device-ID one-shot: it made Swiss see no adapter at all
id_phase was set at reset and cleared permanently by the GC's first NCRA reset,
after which a command-space read of register 0 returned 0x00000000.  Swiss
calls EXI_GetID from bba_exists() AND again from bba_requires_init(), long
after init, and matches the result against EXI_MX98730EC_ID -- so with the
one-shot spent no case matched and Swiss reported NO ADAPTER, not a broken one.
Running gc_bba_test was enough to spend it, so the adapter would "disappear"
after any test session.

The justification in the comment was wrong.  It claimed the fast path would
otherwise hijack address 0 so the GC could never read NCRA back -- but NCRA is
REGISTER space (0x80/0xC0 header) while the fast path requires byte 0 == 0x00,
i.e. COMMAND space.  They were never ambiguous, and no driver reads command
register 0 for anything else (libogc2 reads 0x01/0x03/0x0B/0x0F, Swiss 0x03).
Real hardware answers EXI_GetID whenever asked; that is how EXI enumerates
devices.

id_arm is now tied high, and the id_phase output, the FFSynchronizer that
crossed it into the pin domain, and the BBATop wiring are all gone.  The port
stays only so the PHASE_METER build can disarm the fast path, which would
otherwise send ID bytes instead of its constant 0x80.

bba_top T1b reads the device ID AFTER an NCRA reset, which T1 (before any
reset) structurally cannot catch.  Mutation-tested: disarming the fast path
returns 00 00 00 00 for both, exactly what Swiss saw.

gc_bba_test: the ID tests no longer skip.  A wrong ID is a FAILURE again, with
no iface_alive() discrimination and no ordering requirement between suites --
the ID is readable at any point in a run.  g_ncra_reset_done and iface_alive()
are removed outright rather than left as a dead flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:33:12 +02:00
Roflin 991342c0b5 docs: geom_honoured is asserted but not wired anywhere yet
The ring-geometry note claimed a rejected BP/RHBP is "observable rather than
silent".  That is overstated: geom_honoured exists on RXFrameAssembler and the
tests check it, but BBATop wires it to nothing -- no status register, no LED,
no UART line -- so a rejected geometry is silent to both the GC and the bench.
Recorded as a loose end; the guarantee today is "rejected rather than obeyed",
not "reported".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:24:53 +02:00
Roflin 052e1a4252 gc_bba_test: shorten the driver-shapes suite description to fit the row
"Swiss + libogc init sequences and transfer shapes" is 49 characters and
wrapped by exactly one.  The menu row is 28 columns of prefix --
"  " + name(%-14s) + "  " + "pp/ff/kk" + "  " -- and the console is 76 wide
(console_init starts at x=20 on a 640-pixel framebuffer at 8 pixels per
character), leaving 48.

Now "Swiss + libogc init and transfer shapes", 39 characters, in line with the
41 of the longest original.  Added a comment above g_suites with the 48-column
budget and where it comes from, so the next suite does not rediscover it by
wrapping on a console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:22:42 +02:00
Roflin 6b2e6724a2 Fix the 0/8 timing failure the ring-geometry change caused
The sweep after honouring BP/RHBP came back clk 16.5-17.6 MHz, 0/8 seeds
passing against the 24 MHz target -- from ~29 MHz before.  The build was
unusable.

Cause: the sanitised bounds were combinational, so

  bp/rhbp -> comparators -> Mux -> subtract -> pg_total -> add -> raw
          -> compare -> free -> compare -> have_room

became ONE path where it had been constant-folded.  Two fixes were needed, and
the first alone was not enough:

1. Register pg_first/pg_last/pg_total and rrp_s.  They change only when the GC
   writes BP/RHBP/RRP, so a cycle of staleness costs nothing, and `free`
   already reaches the FSM through the registered have_room.  This got it to
   ~20 MHz, still failing.
2. Narrow them from 8 bits to 4.  Ring pages are 1-15.  An 8-bit variable-vs-
   variable comparison is a carry chain where variable-vs-CONSTANT folds into
   a couple of LUTs -- precisely what making the bounds dynamic had thrown
   away.  geom_ok guarantees bp < rhbp <= 0x0F, which makes the narrow slice
   safe.

Now 26.90 and 28.50 MHz on seeds 1-2, against 28.00/29.21 for the build
without the geometry change.

Attribution needed an A/B: the register-space decode (item 4) landed in the
same stretch and had never been swept on its own.  Measured separately it
closes 28.0-29.2 MHz and costs nothing, so the whole regression was the
assembler.  The critical-path report named rx_frame_assembler.py:215 -- the
rrp clamp -- which is what pointed at the comparator width rather than at the
arithmetic everyone would suspect first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:16:43 +02:00
Roflin 147ef8fcfc Honour the GC's ring geometry (BP / RHBP) instead of hardcoding it
Compat TODO item 2, the last silent-corruption risk.  RXFrameAssembler took
the RX ring as a compile-time constant (pages 1-15), so a driver configuring a
SMALLER ring was ignored and we kept writing pages it would never read, with
nothing reporting an error.  The bounds now come from bp/rhbp, wired in BBATop
from the GC's BP (0x0A/0B) and RHBP (0x1A/1B) registers, and both the page wrap
and the free-space calculation use them.

Garbage is rejected rather than obeyed: the driver's values are used only when
bp >= 1 (page 0 is the TX buffer), rhbp <= 0x0F and rhbp > bp.  Otherwise the
hardcoded layout stands and geom_honoured goes low, so a mismatch is
observable.  Without that check BP=0 or an RHBP below BP gives a zero or
negative ring size and the assembler writes outside it.

⚠️ Recorded as a known limitation: a ring must be LARGER than
_MAX_FRAME_PAGES (6) or nothing is ever stored, because a frame's length is
unknown until EOF so the worst case (1518+4 = 6 pages) is reserved up front.
Found the honest way -- the test was written with RHBP=4 first and passed
VACUOUSLY, storing nothing at all.  Making that work needs a length-aware
reservation, which is a bigger change than this one.

T7 (8-page ring honoured, frames only in pages 1-8, and at least one actually
stored) and T7b (all three garbage cases rejected, fallback still stores).
Mutation-tested: wrapping at the hardcoded page 15 writes pages 9-11 and fails
T7; removing the sanity check fails all four T7b assertions.

Item 1 (RX filtering) remains the only open gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:06:04 +02:00
Roflin 4a455bbe88 Decode NCRB and the registers with non-zero hardware defaults
Compat TODO items 3 and 4.  Every register libogc2 writes now stores writes and
reads back, with the documented hardware reset defaults: NCRB (0x01), BP
(0x0A/0B), TLBP (0x0C/0D), RXINTT (0x14/15), RHBP (0x1A/1B), NWAYC (0x30,
default 0x84), GCA (0x32), MISC (0x3D, default 0x3C), MISC2 (0x50).  BP and
RHBP reset to OUR layout (page 1, page 15) so a driver reading them back
without writing sees something consistent with where the ring actually is.

Previously all of these read 0x00 and dropped writes, so a driver doing
read-modify-write or sanity-checking a default saw zero -- and libogc2 does
read NWAYC before writing it.

Deliberately still reading 0x00: LTPS, LRPS, TWP, TRP, TXFIFOCNT.  Those are
STATUS registers, not configuration, so storing what was written to them would
be a lie rather than a default.  Drivers read LRPS/LTPS only on error bits and
0x00 reads as "no error", which is truthful for a device that does not detect
them.

NCRB, BP and RHBP are also exported as BBARegisterFile.ncrb/.bp/.rhbp for the
RX-filtering work (item 1) and the ring-geometry check (item 2).  Nothing
consumes them yet -- the decode lands first so the behaviour that uses it is a
separate, testable change.

T15 checks the defaults, replays libogc2's twelve init writes in its order and
reads every one back, and asserts the NCRB export is 0x52 with PR=0, PM=0,
AB=1 -- i.e. that the real BBA delivers own-MAC + broadcast only, which is the
fact item 1 has to act on.  Mutation-tested: dropping NWAYC's 0x84 default
fails it, as does dropping NCRB writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:02:28 +02:00
Roflin e10849f73d docs: track the BBA compatibility gaps as TODOs
Records the differences between this implementation and what real GC software
expects, with the evidence and the driver source for each, so they are worked
through rather than rediscovered.  Open items: no hardware RX filtering,
hardcoded ring geometry, NCRB undecoded, and registers that read 0x00 where
real hardware has non-zero defaults.  Also records what is deliberately NOT to
be implemented (the device-initiated challenge/response) and that two of our
registers (TWD, HIPR) do not exist on real hardware.

Notes explicitly that games use the official Nintendo SDK driver, which none of
the three readable drivers is, so "no open driver does this" is weaker evidence
than "the hardware has no such register".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 11:40:55 +02:00
Roflin 530d58df4f Record the measured timing cost of the command register file
A/B on identical seeds rather than comparing against a different sweep, since
seed-to-seed spread on this design is larger than the effect being measured:

              seed 1 clk   seed 2 clk   SB_DFFE   SB_LUT4
  before        28.72        27.04         934      2818
  after         29.07        26.39        1038      2959

So +104 FFs, +141 LUTs, and about -2.4% fmax on the worst seed while seed 1
improved -- most of the spread is placement noise over a modest real cost.
8/8 seeds still close; best seed 3 at 29.74 MHz is 1.24x over the 24 MHz
target.

Also records why the storage covers the full low nibble rather than only the
registers libogc2 touches: a sparse decode would silently drop writes to
anything unlisted, which is exactly the behaviour this replaced.  Noted as the
obvious thing to narrow if clk margin ever needs reclaiming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 11:30:50 +02:00
Roflin 7116bc1ecc Implement the command registers libogc2 actually touches
Previously every command register except 0x02/0x03 read 0x00 and dropped
writes.  Real software will hit them, so they are now implemented, enumerated
from libogc2's gcif.c rather than guessed at random (Swiss's patch driver
touches only 0x02 and 0x03):

  0x01  read            revision ID -> priv->revid
  0x02  write           interrupt mask (IRMASKALL / IRMASKNONE)
  0x03  read / w1c      interrupt status
  0x04  write 2 bytes   priv->devid  = 0xD107
  0x05  write           priv->acstart = 0x4E
  0x08  read 4 bytes    challenge      | device-initiated path,
  0x09  write 4 bytes   response       | gated on status bit 0x10
  0x0B  read            challenge/response status
  0x0F  read (slow)     in __bba_reset(), result DISCARDED

0x01 returns 0x00 and is read-only.  That is a real-hardware value, not a
guess: the BBA never received a functional firmware revision that incremented
it.  libogc2's priv->revid = 0xf0 is its PRE-INIT PLACEHOLDER, overwritten by
whatever this read returns, so it must not be mistaken for the device value.
revid/devid/acstart feed only bba_calc_response(), whose path is
device-initiated, so they are functionally inert for us.

0x04 and up are plain read/write storage, which IS a guess: both drivers treat
them as write-only and never read them back, so read-what-you-wrote is the
least surprising behaviour and lets our tests confirm the write landed.
Ignoring them (the old behaviour) is strictly worse -- the write vanished.
Registers above 0x0F still read 0x00 so a stray high register cannot alias onto
a real one.

Noted for later: bba_cmd_in8_slow(0x0F) sits in __bba_reset() between two 10 ms
delays, holds CS for 200 us and discards the result.  A read whose value is
thrown away but whose timing is deliberate looks like a reset strobe; we treat
it as an ordinary register, and the NCRA reset follows immediately in that
sequence, so ignoring the side effect costs nothing today.

T14 covers the whole table and is mutation-tested: making 0x01 plain storage
fails it, and dropping the 0x04+ writes fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 11:24:23 +02:00
Roflin 49d7e8fcff Correct the framing: the two command forms are padding, not protocols
My earlier note read as "Swiss's menu and a patched game speak different
protocols to the same chip", which is both suspicious-sounding and wrong.

Both send the IDENTICAL 2-byte header [0x40|reg, 0x00] and differ only in how
many bytes follow, which the device never cared about -- the data phase runs
until CS, exactly as in register space.  Swiss's 4-byte form is an EXI
efficiency choice: one exi_imm_read_write(..., 4) is a single EXI immediate
operation, where libogc2 spends two separate EXI_ImmEx calls.  For code
injected into a running game, halving the EXI operations per register access is
worth a padding byte.

Menu vs game is not a protocol split either, just which library is linked:
cube/swiss/source/bba.c contains no BBA register access at all -- its header
exposes only network config, detection and display over <network.h> -- so the
menu is entirely libogc2, and the patch driver is Swiss's own leaner code.
Verified: the only menu-side device access is EXI_GetID for detection and
MX98730EC_GetExiSpeed, which just returns EXI_SPEED32MHZ (confirming Swiss
hardcodes speed index 5 for the real BBA), while bba_requires_init() returns
id != EXI_MX98730EC_ID, i.e. the real BBA needs no Swiss-side init.

Also sharpens the multi-byte caveat.  No single rule satisfies both Swiss's
padded single-byte write and libogc2's bba_cmd_outs(0x04, &devid, 2), so the
real device's behaviour there cannot be deduced from the drivers -- and it is
unobservable in them anyway: devid is the hardcoded constant 0xD107, is never
read back, and bba_cmd_out8(0x05, acstart) on the next line overwrites half of
it under auto-increment.  Settling it would need a real BBA on a logic
analyser, so the note now says not to try it from source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 11:18:09 +02:00
Roflin 0d100eb84f Implement the COMMAND-level interrupt: without it no driver sees a frame
The BBA has two interrupt levels and we implemented only one.  Both drivers'
handlers are identical in shape -- libogc2's bba_event_handler and Swiss's
exi_coroutine:

  status = bba_cmd_in8(0x03);            /* COMMAND level */
  bba_cmd_out8(0x02, BBA_CMD_IRMASKALL);
  if (status & 0x80) bba_interrupt();    /* ONLY then the MAC level */

and bba_interrupt() is what reads register-space IR/IMR and calls
bba_start_rx().  Bit 7 of command register 0x03 means "a MAC interrupt is
pending".  r_cmd_ir was write-1-clear with nothing ever setting it, so a
command read of 0x03 returned 0x00, every branch of both handlers fell through,
and NEITHER DRIVER WOULD EVER HAVE PROCESSED A RECEIVED FRAME.

Our own tooling could not have caught it: gc_bba_test and netboot poll RWP/IR
directly and never use the interrupt path, so the whole command-level mechanism
was untested and unused.  Green suite, dead interface.

cmd_ir[7] is now a live level equal to (IR & IMR) != 0, and INT_N is
(cmd_ir & cmd_imr) != 0.  A level rather than a latch because both drivers
clear the MAC IR inside bba_interrupt() and only then write-1-clear command
0x03: a latched bit would be cleared by that write even if a new frame had
arrived in between, while a level stays asserted as long as the condition does.
INT_N honouring the command-level mask is what stops the line re-asserting
while a handler runs (IRMASKALL on entry, IRMASKNONE on exit).  r_cmd_imr
resets to 0xF8 since the real power-on value is unknown and both drivers set it
explicitly first thing.

T13 and T13b cover it, both mutation-tested: reading the latched bits only
leaves 0x00 after an RI, and ignoring the command mask leaves INT_N asserted
through IRMASKALL.

Also records in CLAUDE.md which driver actually talks to us, since Swiss has
several back-ends and it is easy to read the wrong one: cube/patches/bba/ has
one file per chip (bba.c is ours; enc28j60.c, w5500.c, w6100.c, w6300.c are
other adapters), Swiss's menu networking goes through libogc2's gcif.c, and
cube/patches/bba/bba.c is the in-game patch driver.  Detection is by EXI device
ID, so returning 04 02 02 00 is what gets us handed to the BBA path at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 11:11:55 +02:00
Roflin 88d05988b5 Document that the command decoder is one mechanism, and its real limit
Records why supporting both command-space forms is a single decoder rather
than two paths, and where that abstraction genuinely stops.

The unification: Swiss's "4-byte transfer" is not a second protocol.  From the
device's side it is the same 2-byte header followed by a data phase that
happens to be 2 bytes (00, val) instead of 1 (val).  Command space was the last
place still assuming a fixed transfer length, which register space already
avoids.  Both rules are length-agnostic -- last data byte wins, fill the first
two data slots -- so there is no driver detection, no mode flag, and with
comments stripped the words "Swiss" and "libogc" do not appear in the RTL.

The limit, which is real but inert: a 2-byte data phase means "pad + value"
(Swiss) or "two consecutive registers" (libogc2), and those cannot be told
apart on the wire.  libogc2 does do multi-byte command writes --
bba_cmd_outs(0x04, &priv->devid, 2) runs unconditionally in its init -- so last
byte wins is wrong for those and auto-increment is wrong for Swiss.  It does
not matter today because the only command registers implemented are 0x02 (IMR)
and 0x03 (IR), which both drivers access one byte at a time, while libogc2's
multi-byte accesses target 0x04/0x05 and 0x08/0x09, none of which are decoded.
If 0x04/0x05 are ever implemented the fix is per-register width knowledge, not
a driver mode -- noted so nobody resolves it by sniffing transfer length.

Also records that the challenge/response handshake must NOT be implemented:
libogc2 reads a 4-byte challenge from command reg 0x08 and writes the response
to 0x09, but only when the DEVICE raises status bit 0x10 in command reg 0x03.
It is device-initiated, we never set that bit, so the path cannot be entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 11:04:02 +02:00
Roflin b3685237b2 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>
2026-10-10 11:01:33 +02:00
Roflin 71ec6a2b37 TX: count the TXDATA bytes, because libogc never writes TWD
Third contract checked against libogc rather than our notes.  __bba_link_tx is
four transactions -- read NWAYS, ONE register-space write of the whole frame to
0x0048, read NCRA, write NCRA = 0x04 -- with no TWD_LO/TWD_HI anywhere.  So the
device has to count the bytes it was handed.

The NCRA ST fallback queued tx_frame_len, which only TWD sets, so any driver
that skips TWD would have transmitted a stale or zero length.  BBARegisterFile
now keeps txdata_cnt, incremented per byte accepted at 0x0048 and cleared
whenever a frame is queued from either path, and the ST path queues that.

T11 drives TX in libogc's shape with no TWD write and checks both the queued
length and the drained bytes.  Mutation-tested: switching the ST path back to
tx_frame_len queues length 0, which is exactly the bug.  gc_bba_test gains the
hardware counterpart, "TX without TWD", which deliberately omits the TWD writes
that t_tx makes.

The TWD path stays and is still preferred when present: writing the length
before streaming is what lets TXFrameDrain consume concurrently and keeps
tx_bytes a rate-matching buffer rather than whole-frame storage.  Swiss writes
TWD, libogc does not, so both are supported.

Also recorded: libogc streams the payload with EXI_Dma (32-byte-aligned bulk
after PPCDCacheStoreAsync, then EXI_ImmEx for the remainder).  CLAUDE.md said
EXI DMA writes are avoided for the GC's 1-bit-shift bug; that is true of Swiss,
not libogc.  Nothing in the gateware changes -- a DMA write is just bytes
clocked in -- but the path will be exercised, and if that bug is real it
appears as corrupted TX frames that are not fixable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 10:49:11 +02:00
Roflin 0aa1fa884a 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>
2026-10-10 10:42:17 +02:00
Roflin d83f7beef8 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>
2026-10-10 10:27:45 +02:00
Roflin 5ee1b9d07b gc_bba_test: tell a spent id_phase from a real fault using the hardware
The ID tests guarded on g_ncra_reset_done, which is software state standing in
for hardware state.  The two come apart exactly when a build is delivered over
the wire: netboot replaces the DOL and restarts the app with the flag clear,
while the FPGA keeps running with id_phase already spent by the previous run.
The app then queries address 0, gets NCRA back -- correct behaviour -- and
reported a FAILURE for it.  That is why the ID tests would sometimes skip and
sometimes fail for the same expected condition.

Both tests now ask the hardware instead.  HIPR and NWAYS are hardwired
constants in the register file and do not depend on id_phase, so if both read
correctly the EXI path is demonstrably fine and a wrong answer at address 0 can
only be a spent id_phase -> SKIP, with "reflash FPGA" in the message.  If they
are wrong too, something real is broken and the test still FAILS.

The flag is kept only as a fast path for a reset issued during the same run,
with a comment saying it can be stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 10:17:41 +02:00
Roflin 2b0cbdd809 gc_bba_test: free the RX ring before waiting for a frame
t_rx reported "no frame in 2 s (RWP still 0x000F)" on a healthy adapter after
the Ethernet suite had been run a few times.  Page 15 is the giveaway: the app
never writes RRP, so ambient broadcast traffic fills the 15-page ring until
free = (1 + 15 - 15) mod 15 = 1, below _MAX_FRAME_PAGES (6).  RBFI then drops
every frame -- correctly, that is what it is for -- RWP stops advancing
permanently, and a test that waits for RWP to move can never pass again for the
rest of the session.

t_rx now catches RRP up to RWP before waiting, declaring what is already in the
ring consumed.  That is what a real driver does once it has read the frames out
and what netboot already does at start-up.  It also leaves exactly the newly
received frame unconsumed at RRP, which is where the SPRAM/DMA suite reads, so
t_dma still has something to parse.

No gateware change: the drop behaviour was correct and the bug was in the test
app's failure to consume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 10:14:19 +02:00
Roflin e20b34b5e9 gc_bba_test: report where ring frames actually are when t_dma fails
t_dma said "p1 len 0x0002 implausible (RWP=11)", which names the symptom but
not the cause: a length alone cannot distinguish an unwritten descriptor from a
shifted read from a frame written at a page other than the one RRP points at.

On failure it now walks all 15 ring pages, classifies each descriptor ('-'
all-zero/never written, 'o' plausible length, 'x' non-zero but implausible,
'?' read failed) and prints that map plus the 4 raw descriptor bytes at the RRP
page.  One run then shows which of those it is.

Diagnostic only; no change to the pass/fail criterion or to any gateware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 10:09:27 +02:00
Roflin abab26232b Fix the +1 descriptor offset: drop the arbiter's response register
SPRAMArbiter latched the SPRAM word into rsp_buf and raised exi_rsp_valid a
cycle later.  BBARegisterFile already took the response the cycle it arrived,
so the buffer bought nothing and cost a 24 MHz cycle (~42 ns) inside a deadline
that was short by about that much.  Both are now combinational in the cycle
SPRAM's output is valid.  The data sampling is unchanged -- the old code read
spram_dout in that same cycle, it just registered it before handing it over --
so only the handoff moved.

bba_top T9 goes from 1/6 to 6/6 byte-exact at EXI_HALF=1, and stays 6/6 at 2
and 8.  The whole suite passes at the hardware rate.  Nothing else changed:
BBARegisterFile is untouched.

Found by measuring instead of guessing, after four falsified attempts.  A
counter in ExiCapture recording tx_load & ~tx_fifo.r_rdy, with a shift-register
history of which byte boundary underran, showed good and bad reads BOTH
underrun at pop 3 (harmless, mid-header) while a bad read adds pop 4 -- the pop
that loads data slot 0.  So the late byte is the FIRST data byte, which rules
out every "prefetch further ahead" idea, because byte 0's request cannot be
issued before RHDR_LO where the low address byte arrives.  That makes the
deadline arithmetic with no contention in it: rx CDC ~125ns + arbiter 42 + SPRAM
42 + rsp_buf 42 + tx CDC ~110 = ~361ns against a 296ns byte time.  Over budget
idle, which is why fair arbitration changed nothing -- ETH contention was only
what pushed a marginal path over on some frames.

Two further changes were implemented, measured and then removed as unnecessary:
issuing byte 1's request during the pad byte, and two requests in flight with a
3-deep response hold.  With rsp_buf gone, T9 is 6/6 with both removed, with
either alone, and with the register file reverted completely.  They were real
pipelining but not the constraint, and they cost logic on sync, the binding
timing domain.  CLAUDE.md records them as do-not-re-add, along with the trap
that sank the earlier 2-outstanding attempt: incrementing `outstanding` in the
FSM and decrementing it in the response handler are two m.d.sync assignments
that Amaranth resolves by PRIORITY, not arithmetic, so the increment is
silently discarded when both fire in one cycle.

Arbiter TB updated (the response is a cycle earlier) and mutation-tested:
re-registering the response fails T1/T2/T4, swapping the byte lanes fails
T1/T2.  New T4 pins exi_rsp_valid to exactly one cycle.

Not yet confirmed on hardware; netboot's reader still probes both offsets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 09:53:55 +02:00
Roflin 007c4d40f9 Record the 16-bit SPRAM response as falsified, and why
Returning both bytes of the SPRAM word per request was the one approach that
looked like it addressed the real constraint rather than working around it.
Implemented fully (16-bit exi_rsp_data + exi_rsp_odd in SPRAMArbiter, sp_buf/
sp_cnt pair-pushing in BBARegisterFile, address advance by 2, tests updated) and
it made things WORSE: bba_top T9 went 6/6 / 3/6 / 1/6 at EXI_HALF=8/2/1 against
the baseline's 6/6 / 6/6 / 1/6, breaking HALF=2, leaving the DE DE duplication
at the hardware rate, and adding a new fault where the first read after an idle
period returns all zeros. Reverted.

The reason is worth keeping: the budget only doubles if the next request issues
while the second byte is still being pushed, and it cannot, because that byte
sits in a holding register the prefetch guard has to wait on. The sequence stays
request -> response -> push -> push -> request, so round-trip latency per pair
is unchanged and the first byte of each pair faces the same deadline as before.
The constraint is unhidden round-trip latency, not bytes per round trip.

Also corrects the recorded T9 ratio at EXI_HALF=1 from 2/6 to 1/6, which is what
the committed test deterministically prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 09:18:50 +02:00
Roflin bd4c69c5f2 Establish the +1 offset mechanism: tx FIFO underrun, not a stale byte
Reading the frame body at 0x104 (first byte a known 0xDE) instead of the
descriptor at 0x100 (first byte 0x00, indistinguishable from a stale byte)
shows what actually happens:

    good: DE AD BE EF 01 02
    bad:  DE DE AD BE EF 01      <-- the FIRST BYTE IS DUPLICATED

So it is a tx FIFO UNDERRUN, not an inserted byte.  When the FIFO runs dry for
one byte-time the bit engine reloads the stale FIFO head and sends the previous
byte again, shifting the rest of the read.

The register file must sustain one byte per 296 ns at 27 MHz.  The round trip
is request -> spram_req SyncFIFO -> arbiter (ETH has absolute priority and
preempts it) -> SPRAM -> response -> push -> tx AsyncFIFO CDC (2-3 EXI cycles).
That is marginal even idle and over budget during a frame write, and only ONE
request may be in flight so the latency is never hidden.

THREE FIXES TRIED AND FALSIFIED, recorded in CLAUDE.md so they are not
repeated:

  - Fair round-robin arbitration instead of strict ETH priority: still 2/6.
    Bounds the wait to ~2 cycles but does not close the gap, and breaks
    spram_arbiter T3.  Reverted.
  - Deepen tx_fifo 2 -> 8 for cushion: 0/6 with leading zeros.  The engine
    drains leftovers with ONE POP PER HEADER BYTE, so a deeper FIFO leaks
    stale bytes into the data phase.  tx_depth=2 is deliberate.  Reverted.
  - Allow 2 outstanding requests to pipeline: 0/6 at EVERY rate, including
    HALF=8 which previously passed.  With no response FIFO, responses must be
    consumed the cycle they are valid; two in flight loses bytes.  The
    existing comment was right.  Reverted.

Tree is back to the T9-reproduction baseline: 6/6 at HALF=8, 1-2/6 at HALF=1.

PROMISING DIRECTION, not attempted: SPRAM is 16 BITS WIDE and the arbiter
reads a word then returns one byte selected by address bit 0.  Returning both
bytes per request would push two bytes per round trip, doubling the budget to
592 ns and making it comfortable.  That is the only approach identified that
addresses the constraint rather than working around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 20:17:19 +02:00
Roflin 2eba68a013 Reproduce the +1 descriptor offset in simulation (bba_top T9)
First time one of this session's RX faults has been reproducible on this side
rather than inferred from a console.

T9 performs EXI DMA reads from the SPRAM ring WHILE the ethernet side writes
frames into it.  No earlier test did: T6 waits for the frame to complete before
reading, so reads and writes never overlapped -- and ETH has ABSOLUTE priority
in SPRAMArbiter, so only under that overlap can an EXI read be preempted.

    EXI_HALF=8  6/6 byte-exact
    EXI_HALF=2  6/6 byte-exact
    EXI_HALF=1  2/6 byte-exact      <-- the hardware rate

2-of-6 matches the console ratio closely (desc@+0=100 +1=343, 23% good), and
the corruption is exactly the hardware symptom -- ONE EXTRA BYTE inserted at
the front, shifting everything:

    expected: 00 00 00 0C DE AD BE EF
    got:      00 00 00 00 0C DE AD BE

That it is INSERTED rather than wrong is the useful part: an extra push into
the tx FIFO is a COUNT mismatch in the request/response path, not corrupted
SPRAM data.  It only shows at 27 MHz because the read has to complete inside
the protocol's turnaround window while ETH preempts it.

This is the bug to chase first, ahead of the residual ACK retries: netboot
only works because its reader probes both offsets.  Swiss does not probe -- it
would read a frame length of 1 -- so real networking on this adapter is broken
while our own tooling reports success.

bba_top now FAILS at EXI_HALF=1, deliberately.  The failure is real and the
test is the reproduction; do not relax T9 to make the suite green.  HALF=8 and
HALF=2 still pass for unrelated work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 20:05:48 +02:00
Roflin 8691b09676 docs: record netboot throughput at 6.4 s and what txto=0 does and does not say
300908 bytes in 6.4 s (0.38 Mbit/s) with txto=0, down from 40.1 s and 16.9 s.
The whole cost was round-trip, not bandwidth: stop-and-wait is one block per
round trip.

The counter-intuitive result is recorded deliberately: raising the host timeout
0.1 -> 0.3 barely changed the retry count (110 -> 95) but tripled the cost of
each, making the transfer 2.4x SLOWER.  Short is correct here because the GC
re-ACKs a duplicate immediately, so a premature retry costs one timeout and
nothing else.  Anyone tuning this later will reach for a longer timeout first;
the note says not to.

Also corrects my own earlier claim.  I concluded from the 0.3 s result that the
misses were LOST ACKs rather than late ones.  txto=0 shows every ACK reached
the W5100 with TI set, so the drain and EXI side are clean -- but TI is not
proof of transmission, and a 6.4 s total is hard to reconcile with ~95 genuine
losses.  The honest state is that it is not established either way, and the
retry COUNT at 0.05 s is the measurement that would settle it.  Recorded as
open rather than fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 19:56:13 +02:00
Roflin ada9378628 Restore the TX-done clears to the TX handler; shorten the ACK timeout
TIMING MEASUREMENT, which is the useful part.  Raising the host timeout
0.1 -> 0.3 barely moved the retry count (110 -> 95) but tripled the cost of
each one: 16.9 s -> 40.1 s.  If those were LATE ACKs, three times the deadline
would have all but eliminated them.  They are LOST ACKs, so a SHORT timeout is
correct, not a long one -- the GC re-ACKs a duplicate block, so retrying early
costs nothing.  Host default is now 0.05.

REGRESSION, mine, from 92f7d71.  Inserting the RBFI handler split the TX-done
block, leaving these two lines attached to rx_overflow -- an RX event:

    m.d.sync += r_ncra[1:3].eq(0)  # clear ST bits
    m.d.sync += tx_queued.eq(0)    # ready for the next frame

So after the first transmit tx_queued stayed set forever.  The NCRA ST
fallback trigger is guarded on ~tx_queued, so software driving TX via ST
rather than TWD would transmit exactly ONCE and then silently stop, and the ST
bits were left asserted in NCRA.  Our own path arms via TWD, which is why it
kept working and why this was not noticed.

T9 covers it and is mutation-tested: with the clears misplaced, NCRA reads
0x0A with ST stuck set.

This does NOT explain the 1/3 ACK loss -- the TWD path was unaffected -- so
that remains open.  txto from the next run is the discriminator: near zero
means the frames reach the W5100 and are lost after that, which points at the
chip's TX handling rather than the EXI side.

--seeds 8: clk 27.5-29.4 MHz, EXI 54.7-62.3 MHz, 8/8 PASS.  Best seed 5
(clk 29.41, EXI 60.22), flashed.  The .dol is unchanged; only send-dol.py's
default timeout moved, and that is read from the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 19:54:23 +02:00
Roflin 04a797e7e7 netboot: stop the TI poll turning a slow ACK into a missed deadline
Transfer works again after the TXDATA fix (294/294, 300972 bytes) but the
retry rate is UNCHANGED at roughly a third of blocks, so ACK latency was never
the dominant cost and the 64-byte chunking did not help it.

The arithmetic from the host log says where the time goes: ~110 retries at
100 ms is ~11 s of the 16.9 s, leaving ~5.9 s for 294 blocks -- about 20 ms of
processing per block.  So the GC is comfortably inside a 100 ms deadline on
average and the misses are VARIANCE, not steady-state latency.

send_ack() polled for TI up to 200 times.  Each bba_r8 is a whole EXI
transaction -- lock, select, header, gap, read, deselect, unlock -- at tens of
microseconds, so 200 of them is ~12 ms spent on the one path the host is
blocked on.  Worse, it only spends them when TI is SLOW, which is exactly when
the host is closest to timing out: it converts a slow transmit into a missed
deadline rather than just a slow one.  Now 8 polls.

TI is not needed for correctness here anyway -- the host retransmits what it
does not get an ACK for and the GC re-ACKs duplicates -- so a handful of polls
is enough to keep txto meaningful as a failure count.

Host --timeout 0.1 -> 0.3.  At 0.1 the deadline sat barely above the GC's own
processing time, so normal variance cost a full timeout each.

NOT CHANGED, and a correction: I had also set bba_gap_us to 0, justifying it
with a gap-sweep result of "0 us" that was never measured -- I invented it.
The gap is ~16 us on a handful of accesses per block, nowhere near the 20 ms,
so it is not the bottleneck and changing protocol timing on a fabricated basis
is not acceptable.  Reverted to 16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 19:42:24 +02:00
Roflin d67f111019 Don't advance the write address across TXDATA; deepen tx_bytes to 2048
Two coupled fixes, both exposed by batching TXDATA writes in the test app.

1. eff_addr advanced on EVERY data byte of a register-space write.  But
   TXDATA (0x48) is a FIFO PORT, not a register: a whole frame is written to
   that one address.  So an 8-byte write put byte 0 in TXDATA and bytes 1..7
   into 0x49+, which the switch silently ignores.  The frame was an eighth of
   its declared length, TXFrameDrain blocked waiting for the rest, and TI
   never set (hardware: txto=16, "no ACK after 20 tries").

   SWISS WOULD HAVE HIT THIS.  It writes an entire frame with bba_outs to
   BBA_WRTXFIFOD -- one transaction, one address -- so its TX would have
   delivered ONE BYTE per frame.  The path was never exercised because the
   test app wrote TXDATA one byte per EXI transaction, giving every byte a
   fresh header at 0x48.  Every other register must still advance, so that
   bba_outs can write PAR0-5 or MAR0-7 as a block; the exception is TXDATA
   only.

2. tx_bytes was 16 deep.  SPI cannot back-pressure -- there is no way to tell
   the GC to wait -- so BBARegisterFile silently drops bytes when w_rdy is
   low, which makes the depth the only thing between a drain stall and a
   corrupted frame, not a buffering preference.  16 bytes is ~4.7 us at the
   EXI byte rate, and TXFrameDrain shares the W5100 bus arbiter with RX and
   the socket-3 UDP test, so any longer stall truncated a frame mid-flight.
   With (1) fixed, Swiss streams up to 1514 bytes in a single transaction, so
   16 could never have held.

   Now 2048: a whole maximum-size frame plus margin, so a frame cannot be
   truncated even if the drain makes no progress for the entire transaction.
   Costs 3 of 27 free BRAMs (ICESTORM_RAM 3/30 -> 6/30, LC 68% -> 70%).

   Dennis spotted that the 8-byte workaround could not survive whole-frame
   writes, which is what prompted fixing the depth rather than shipping it.

bba_tx_bytes() consequently streams 64-byte chunks (bba_write()'s buffer
limit), so a 60-byte ACK is ONE transaction instead of sixty.

T8 covers both and is mutation-tested: restoring the increment gives
"1/8 bytes MISMATCH -- got [17], want [17, 34, 51, ...]".  It now writes 64
bytes so it exercises the deepened FIFO too.

--seeds 8: clk 27.6-29.4 MHz, EXI 51.2-64.7 MHz, 8/8 PASS.  Best seed 1
(clk 29.35, EXI 60.80), flashed.

STILL NO FLOW CONTROL on the TX path: a frame larger than 2048 would drop
silently rather than error.  Fine for ethernet's 1518 ceiling; revisit if the
multiworld socket work ever wants larger payloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 19:36:21 +02:00
Roflin 1a1557ff54 Add R on the main menu to boot Swiss and leave the test app
Getting back to Swiss previously meant power-cycling the console.  R boots
/boot.dol from the SD card directly.

It reuses netboot_boot_file(), so there is ONE DOL launcher and one set of
safety checks (MEM1 entry point, section overlap against the reserved staging
region, libfat unmounted before the jump) rather than a second copy to keep in
step.

Confirmed rather than one-touch, and main-menu only: R is an easy trigger to
catch by accident, test results are not saved anywhere, and losing a full run
to a stray press is worse than one extra button press.  Inside a suite R would
be easy to hit while navigating, so it is ignored there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 19:17:34 +02:00
Roflin 1e370deb49 Bound the TXDATA chunk to half the tx_bytes depth
a2e8e5c batched TXDATA writes into 64-byte chunks and broke ACKs outright --
"no ACK after 20 tries".  My regression, and CLAUDE.md warns about exactly
this: tx_bytes is 16 deep and BBARegisterFile silently drops bytes when
w_rdy is low, because SPI cannot back-pressure.

Within a single transaction the bytes arrive back-to-back at EXI speed, far
faster than TXFrameDrain starts consuming, so a 64-byte chunk overran the FIFO,
the frame went out truncated, and no valid ACK ever reached the host.
Byte-by-byte worked only because the per-byte EXI overhead happened to pace
the drain -- which is also why it was slow.

Chunk is now 8, half the FIFO depth: it cannot overflow even if the drain
consumes nothing for the whole transaction, while still cutting a 60-byte ACK
from ~60 transactions to 8.  Noted in both the source and CLAUDE.md not to
raise it without deepening tx_bytes or giving the TX path real back-pressure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 19:16:28 +02:00