udpstream: LANCE command payload -- peek/poke/jump/reboot, benchmark, driver-matched RX

lance_cmd.c is a payload that drives the LANCE with its own code (no ROM
calls) and answers a command protocol on ethertype 0x88B6, so a unit whose
chip we have taken over can still be inspected:

  read (peek), stats, poke, jump, reboot

The reply path was dead for a while and the cause was not the chip: the RX
ring had been grown to 16 entries (0x3020..0x309F) while TXR stayed at
0x3060, so all eight TX descriptors lay inside the RX ring.  The chip's RX
write-back set OWN in what we believed were TX descriptors, and every reply
was discarded by transmit_poll() as "owned but not start of packet"
(r 00309A 8000 / w 00309A 0000 in the DMA log).  TX ring moved to 0x30C0,
with a compile-time guard so the rings cannot overlap again.

Receive path aligned with the three drivers available locally (4.4BSD
pmax if_le.c, NetBSD am7990.c, Linux 7990.c):

  * STP *and* ENP required on one descriptor, else the frame is counted as
    chained and dropped rather than parsed from the middle of a buffer;
  * the 4-byte FCS the chip writes into the buffer is subtracted, as all
    three do (rmd3 - 4);
  * re-arm restores RMD2 and clears RMD3, ownership written last;
  * TX kick is TDMD|INEA (no reference driver re-writes STRT);
  * a TX slot still owned is skipped instead of clobbered.

RX buffers are 1526 B so a full 1514-byte MTU frame fits one descriptor;
1500 B would have chained it.

vtcmd.py drives all of it; bench-sweep.sh sweeps size x rate.  Measured on
the real VT1200 (10 Mbit half-duplex hub): 856-858 fps of 1514-byte frames
at 99.9-100% delivery, i.e. wire limited, and 1500 fps of 60-byte frames at
99.8-99.9%.  NETWORK_REPORT.md sections 68-72 record the evidence, including
the 64-descriptor build that died with an illegal opcode on the unit and was
reverted.
This commit is contained in:
arti committed 2026-10-30 16:57:15 +02:00
1 parent e51c7b94cf
commit 0563cb08e8
5 files changed
+1066

No files matched your search

+216
View File
@@ -4479,3 +4479,219 @@ does so with no ROM code: our init block, our rings, our descriptors, our
Both are fixed: `X` added, and the MAC is now one word per row (`MAC0`/`MAC1`/
`MAC2`) with `REST` moved to y 878.
## 68. Ring overlap: the reply was discarded by the chip, not by the wire
`lance_cmd.c` (the command/benchmark payload) received commands correctly —
`cmds` incremented and `laststat` was `0` — but no reply ever reached the host.
The TX descriptor read back as `TMD1 = 0x8000` (OWN set, STP and ENP clear)
although the code had written `0x8300`.
The DMA log (`VT1200_LANCE_DMA_LOG`) showed the chip doing this on **all eight**
TX descriptors, round-robin, forever:
```
r 00309A 8000 FFFF pc=018C9B00 cyc=275084712
w 00309A 0000 FFFF pc=018C9B00 cyc=275084712
```
Read `0x8000`, write back `0x0000` — that is `transmit_poll()`'s "owned but not
start of packet" path (`am79c90.cpp:329`), which clears ownership and advances
the ring without transmitting anything.
The cause was an address overlap, and it was arithmetic, not the chip:
```
RX ring: 0x3020 .. 0x309f (16 entries x 8 B)
TX ring: 0x3060 .. 0x309f
```
The RX ring had been grown from 8 entries to 16 (to survive bursts) but `TXR`
was left at `0x3060`, so **all eight TX descriptors lay inside the RX ring**.
The chip's RX write-back set `OWN` in what the payload believed were TX
descriptors, so every reply was rejected as a continuation fragment.
Fixed by moving the TX ring to `0x30C0`, plus a compile-time guard so the same
mistake cannot compile again:
```c
typedef char rx_tx_rings_do_not_overlap[(RXR + NRX * 64 <= TXR) ? 1 : -1];
```
This also explains the earlier "reply buffer has the broadcast address as its
source MAC": that was a second, independent bug (the reply copied the request's
destination MAC into its own source field), fixed separately. A frame whose
source is multicast is dropped by the tap, so it would have failed even with
correct descriptors.
## 69. Packet handling cross-checked against the four drivers
Four implementations are available locally and were read line by line:
`original-bsd/sys/pmax/dev/if_le.c` (the firmware's ancestor, section 52),
`NetBSD-src/sys/dev/ic/am7990.c`, `linux/drivers/net/ethernet/amd/7990.c`, and
`linux/drivers/net/ethernet/amd/7990.h`.
| | 4.4BSD `if_le.c` | NetBSD `am7990.c` | Linux `7990.c` | this payload |
|---|---|---|---|---|
| RX buffer | `LEMTU` **1518** (`if_lereg.h:13`) | `LEBLEN` **1536** (`lancereg.h:116`) | `PKT_BUFF_SIZE` **1544** (`7990.h:45`) | 1526 |
| ring | `LERBUF` | `sc_nrbuf` | 8 RX / 2 TX (`7990.h:35-36`) | 16 RX / 8 TX |
| RX length word | `-LEMTU` (`:344`) | `-LEBLEN \| 0xf000` (`:186`) | `-RX_BUFF_SIZE \| 0xf000` (`:200`) | `0xFA0A` |
| complete frame | `(rmd1 & (STP\|ENP)) == (STP\|ENP)` (`:630`) | same, `"dropping chained buffer"` (`:247`) | `(bits & POK) != POK` (`:303`) | now both bits |
| FCS | `len - 14 - 4` (`:691`) | `rmd3 - 4` (`:256`) | `(mblength & 0xfff) - 4` (`:322`) | now `- 4` |
| re-arm | clear RMD3, then OWN (`:661`) | `OWN`, `rmd2 = -LEBLEN\|ONES`, `rmd3 = 0` (`:259`) | clear mblength, then OWN (`:346`) | now all three |
| TX descriptor | `OWN\|STP\|ENP`, `tmd2=-len`, `tmd3=0` (`:485`) | same (`:513`) | `POK\|OWN`, `(-len)\|0xf000` (`:583`) | same |
| TX kick | `TDMD\|INEA` (`:491`) | `INEA\|TDMD` (`:524`) | `INEA\|TDMD` (`:583`) | now `INEA\|TDMD` |
| slot still owned | `panic("lestart")` (`:471`) | `"missing buffer"`, break (`:482`) | stop queue | skip, count `txfull` |
### 69.1 Why the buffer must be ≥ 1518
All three size the RX buffer at `ETHERMTU + 14 + 4`: the chip writes the
**FCS** into the buffer and includes it in `RMD3`. The payload's counter showed
this before it was fixed — a 60-byte frame was counted as 64 bytes. The earlier
1526 was therefore already legal; a 1500-byte buffer would not have been, and
`RMD1_ENP` handling alone would have miscounted the tail of a chained frame as a
whole frame.
### 69.2 What changed in the payload because of this
1. a frame must have **STP and ENP** on the same descriptor, or it is counted as
`chained_dropped` and re-armed without being parsed;
2. the byte counter subtracts the 4-byte FCS;
3. re-arm restores RMD2 and clears RMD3, with ownership written last;
4. the TX kick is `INEA|TDMD`, not `STRT|TDMD` — no reference driver re-writes
`STRT`, and 4.4BSD and NetBSD both write exactly `TDMD|INEA`;
5. a TX slot that is still owned is skipped and counted in `txfull`.
## 70. The command protocol: read and stats over the wire
The payload (ethertype `0x88B6`, magic `'K' 'S'`) answers two commands, which is
what makes the machine debuggable while the OS is down:
| cmd | request | reply |
|---|---|---|
| 1 `read` | 32-bit big-endian **bit** address, 16-bit length (≤ 64) | status, then the bytes |
| 2 `stats` | — | `RXF`, `bytes`, `cmds`, `chained_dropped`, `txfull`, `csr0_err` |
| other | — | status `0xE002` |
`harness/udpstream/vtcmd.py` drives it: `stats`, `read --addr --len`, and
`bench`. The first frame after takeover is routinely lost (the ring is armed
before the receive loop runs), so requests are retried three times.
Confirmed in MAME and on the unit: `read 0x018D0000 --len 16` returned
`01 00 00 00 55 55 …`, the payload's own MARK block — arbitrary memory over
Ethernet, with no ROM code and no OS.
## 71. Receive throughput on the real VT1200
The unit and the host are joined by a **10 Mbit half-duplex hub** (the VT1200 has
coax Ethernet). That fact governs every number below: above the receiver's
capacity the *delivered* rate does not plateau, it collapses, which is what a
half-duplex collision domain does under overload.
`bench` sends 60-byte and 1514-byte frames at a paced or unpaced rate, then reads
the payload's counters. Background traffic is subtracted by taking a `before`
reading; the payload's own control frames are subtracted explicitly
(`frames = ΔRXF − Δcmds`).
| frame | offered | delivered | rate | `csr0_err` |
|---|---|---|---|---|
| 1514 B | 1717–1723 fps, unpaced | **99.9–100 %** | **856–858 fps, 10.37–10.39 Mbit/s** | `0x9000` |
| 60 B | 3001 frames @ 1500 fps | **99.8–99.9 %** (5 runs) | 1498–1499 fps, 88 KiB/s | `0x9000` |
| 60 B | 6001 frames @ 2000 fps | 58 % | 1165 fps | `0x9000` |
| 60 B | 12001 frames @ 4000 fps | 0.3 % | 12 fps | `0x9000` |
Readings and their limits:
* **Full-MTU reception is wire-limited, not code-limited.** 856–858 fps of
1514-byte frames is 10.37–10.39 Mbit/s of frame bytes; with preamble, FCS and
inter-frame gap that is at or slightly above the nominal 10 Mbit rate
(theoretical maximum for this frame size is ~813 fps at exactly 10.000 Mbit/s,
so the link clock is a few percent fast or the sender's pacing is). Nothing was
dropped.
* **Minimum-size frames sustain ~1500 fps with ~100 % delivery.** Above that the
ring overflows and the hub also collides, and the two are not separable from
the outside.
* `csr0_err = 0x9000` is `ERR|MISS`: **the chip did run out of descriptors**, but
the bit is **sticky** in this build (it ORs CSR0 on every pass), so it records
"at some point", not "how many". It was clear before the 2000/4000 fps runs and
set afterwards, which is consistent with those runs overrunning a 16-deep ring.
A per-event counter is the next refinement; the sticky bit cannot attribute
the misses to a particular run.
* The payload's `stats`-command handling stalls the receive loop for the length
of one reply transmit, so a burst that coincides with a command can produce
misses that a pure data burst would not.
### 71.1 What is proven and what is inferred
**Proven:** the payload receives every frame offered up to the wire limit at full
MTU; the chip reports no error bits at that rate; 1500 fps of 60-byte frames is
reproducible at 99.8–99.9 % over five runs; the reply path works (`stats` and
`read` both answer on the unit).
**Inferred, not proven:** that the collapse above ~2000 fps of 60-byte frames is
the half-duplex hub rather than the LANCE. `ERR|MISS` proves the ring overflowed
at 2000–4000 fps, which is sufficient to explain *loss*; it does not explain why
the *delivered* rate falls from 1165 fps to 12 fps as the offered rate rises —
that shape (rather than a plateau) is characteristic of collision collapse, but
the unit's side of the hub is not instrumented.
### 71.2 A 64-deep ring crashed the unit
Raising `NRX` to 64 (ring at `0x3020`–`0x321F`, TX ring and buffer moved to
`0x3240`/`0x3300`) ran correctly in MAME but died on the unit with an **illegal
opcode** during the first 60-byte burst. The 64 buffers grow the RX buffer pool
from 24 KB (`0x4000`–`0x9F78`) to 97 KB (`0x4000`–`0x1B78A`) of low RAM, and the
firmware's OS is still resident while the payload holds the chip. The payload was
reverted to the proven 16-deep layout; the illegal opcode is **not reproduced in
MAME**, so the memory hypothesis is an inference, not a demonstrated cause.
## 72. peek / poke / jump / reboot, so a taken-over unit can be driven
The command byte selects the operation; addresses are **bit** addresses
throughout, exactly like every pointer the TMS34010 uses.
| cmd | name | request | reply |
|---|---|---|---|
| 1 | `read` / peek | `[17..20]` address, `[21..22]` length (≤ 64) | status, then the bytes |
| 2 | `stats` | — | `RXF`, `bytes`, `cmds`, `chained_dropped`, `txfull`, `csr0_err` |
| 3 | `poke` | `[17..20]` address, `[21..22]` length (≤ 1024), `[23..]` bytes | status, 2-byte count written |
| 4 | `jump` | `[17..20]` address | status, 4-byte target, **then control is transferred** |
| 5 | `reboot` | — | status, 4-byte target, **then control is transferred** |
| — | — | — | `0xE002` unknown, `0xE001` read too long, `0xE003` poke truncated |
The reply is transmitted and its descriptor completion waited on **before** the
transfer, so the host always gets an acknowledgement.
### 72.1 `jump` needs the entry point, not `.text`
The COFF entry is **`0x018CBEB0`**; `0x018C0000` is only the start of `.text`.
Measured in MAME:
* `jump 0x018C0000` → payload stops answering and does not come back;
* `jump 0x018CBEB0` → the payload restarts its own `crt` and answers again with
counters back to zero (`RXF=1`, i.e. just the `stats` request itself).
### 72.2 `reboot` jumps to the ROM reset vector
`reboot` reads a 32-bit word from bit `0xFFFFFFE0` and jumps there. On this ROM
that word is `0x3F200040`, the same entry the CPU takes at power-on. The first
instructions there are (`harness/mame/reset.dasm`):
```
3F200040: 0360 DINT
3F200050: 0550 SETF 10h,0,0
3F200060: 0740 SETF 20h,0,1
3F200070: 5600 CLR A0
3F200080: 0580 00D0 0640 MOVE A0,@64000D0h,0
...
```
`DINT` first, then the DUART control registers — so the entry does not depend on
the state a payload leaves behind, and at real power-on `SP` is undefined
anyway, which is the condition we reproduce.
MAME evidence: after the reply, the PC leaves the payload (`0x018Cxxxx`) for ROM
(`0x3FB43E40` at the next sample) and the payload stops answering. What is **not**
shown yet is that the unit comes back *without a power cycle* — in MAME the OS
was never down, so a warm restart is indistinguishable from the OS continuing.
That test needs the real machine and is the reason the command exists.