udpstream: payload sends and receives raw Ethernet with its own LANCE driver

lance_bcast.c drives the chip entirely on its own -- init block, PADR/LADRF,
eight TX and eight RX descriptors, INIT/IDON/STRT, TDMD, completion polling --
and test-bcast.sh runs it in MAME and reports:

  TXD=6  RXF=5  ET=0x88B5  source MAC 0xC6FC  ok=1  PASS

The tap capture separates the directions: six 60-byte 0x88b5 broadcasts from
08:00:2b:16:4b:5a (our payload) and 49 from the host. RXF counts host frames
(early stop at five) and the recorded source MAC is the host's.

Fixes behind it: per-frame TX descriptor (the chip advances its own ring
position), cfg-net needed for a tap to exist at all, pkill -x instead of -f,
and two bit-vs-byte addressing bugs.
This commit is contained in:
arti committed 2026-10-30 16:21:02 +02:00
1 parent dfc968e906
commit f251672d5d
5 files changed
+271 -15

No files matched your search

+56
View File
@@ -4380,3 +4380,59 @@ unrelated to the takeover payload.
```sh
ping -c3 -W2 100.103.100.103
```
## 66. The payload sends and receives on the wire — with its own driver code
A standalone payload (`harness/udpstream/lance_bcast.c`) now drives the LANCE
with **no ROM calls at all**: it writes its own init block (PADR, LADRF,
rings), programs `CSR1/CSR2/CSR3`, runs `INIT`/`IDON`/`STRT`, owns eight TX and
eight RX descriptors, kicks `TDMD`, and reads completions itself.
### 66.1 The test
`harness/udpstream/test-bcast.sh` runs it in MAME and reports the result:
```
== results ==
TXD (frames transmitted) = 6
RXF (frames received) = 5
last ethertype = 0x88B5
last source MAC hi word = 0xC6FC
ok = 1
payload broadcasts seen on tap-mess-1000-0: 55
== PASS ==
```
and the tap capture separates the two directions:
```
6 08:00:2b:16:4b:5a > ff:ff:ff:ff:ff:ff, ethertype 0x88b5, length 60 <- our payload
49 c6:fc:cc:16:f5:66 > ff:ff:ff:ff:ff:ff, ethertype 0x88b5, length 60 <- the host
```
`TXD = 6` is the payload's own counter for descriptors it armed and saw
complete; `RXF = 5` counts descriptors the chip filled with host frames (the
listen loop stops early at five), and the recorded source MAC `0xC6FC…` is the
host's, not ours — so it really consumed frames from the wire rather than its own
transmissions.
### 66.2 What it took (all of these were my bugs, not the chip's)
| symptom | cause |
|---|---|
| `TMD3 = LCAR`, no carrier | MAME's `-networkprovider taptun` only *permits* the module; the interface choice lives in `harness/mame/cfg-net/vt1200.cfg`. An empty cfg dir means no tap is ever opened |
| `TXD = 1` then timeouts | the payload always armed **descriptor 0**; the chip advances its own ring position after each completion, so frames 2..n were kicked at descriptors it had already passed |
| test killed instantly | `pkill -f mame0289/vt1200` matched the wrapper shell's own command line; `pkill -x vt1200` is the safe form |
| markers read as `0x55555555` | two bit-vs-byte addressing mistakes: the payload called `rb(buf + 12)` with a *byte* address, and the script read `cell()`'s *bit* offsets as byte offsets |
| payload finished before the host frames | its listen window was too short and it returned, letting the OS reuse the marker memory; it now listens for 400M iterations, stops early at five frames, and holds after drawing the panel |
### 66.3 What this establishes
* The register, ring, descriptor and DMA behaviour documented in this report is
sufficient to build a working Ethernet driver: our code transmits frames that
appear on the wire with the terminal's MAC, and receives frames sent to it.
* No firmware routine is involved — the payload is loaded by the COFF loader and
runs from RAM.
* Practical consequence: a payload can now speak any Ethernet protocol it wants
(ARP, IP, a custom one) without the OS's stack, which is the foundation for a
wire-based loader or a faster frame path than the OS's ~20 datagrams/s.
Binary file not shown.
+29 -15
View File
@@ -255,14 +255,14 @@ int main()
/* --- chip init: normal mode, our MAC, accept everything --- */
csr_w(0, CSR0_STOP);
w16(INITB + 0 * 16, 0x8000); /* MODE: PROM, TX/RX enabled */
w16(INITB + 0 * 16, 0x0000); /* MODE: normal, TX/RX enabled */
w16(INITB + 1 * 16, 0x0008); /* PADR 08:00:2b:16:4b:5a */
w16(INITB + 2 * 16, 0x162b);
w16(INITB + 3 * 16, 0x5a4b);
w16(INITB + 4 * 16, 0xFFFF); /* LADRF: all multicast */
w16(INITB + 5 * 16, 0xFFFF);
w16(INITB + 6 * 16, 0xFFFF);
w16(INITB + 7 * 16, 0xFFFF);
w16(INITB + 4 * 16, 0); /* LADRF: no multicast */
w16(INITB + 5 * 16, 0);
w16(INITB + 6 * 16, 0);
w16(INITB + 7 * 16, 0);
w16(INITB + 8 * 16, (RXR >> 3) & 0xFFFF);
w16(INITB + 9 * 16, 3 << 13); /* 8 RX descriptors */
w16(INITB + 10 * 16, (TXR >> 3) & 0xFFFF);
@@ -315,23 +315,30 @@ int main()
txd = 0;
for (n = 0; n < 6; n++) {
wb(TXBUF + 14 * 8, n); /* sequence byte */
w16(TXR + 32, (0x10000 - txlen) & 0xFFFF); /* -len */
w16(TXR + 16, TMD1_OWN | TMD1_STP | TMD1_ENP | (((TXBUF >> 3) >> 16) & 0xFF));
w16(TXR + n * 64 + 32, (0x10000 - txlen) & 0xFFFF); /* -len */
w16(TXR + n * 64 + 16,
TMD1_OWN | TMD1_STP | TMD1_ENP | (((TXBUF >> 3) >> 16) & 0xFF));
csr_w(0, 0x0042 | CSR0_TDMD); /* STRT | INEA | TDMD */
for (idle = 0; idle < 40000; idle++) {
if (!(r16(TXR + 16) & TMD1_OWN)) {
for (idle = 0; idle < 8000; idle++) {
if (!(r16(TXR + n * 64 + 16) & TMD1_OWN)) {
txd = txd + 1;
break;
}
}
for (idle = 0; idle < 120000; idle++) {
for (idle = 0; idle < 20000; idle++) {
}
}
dot(3);
/* --- listen: count what arrives, remember the last ethertype and MAC --- */
rxf = 0; lastet = 0; m0 = 0; m1 = 0; m2 = 0;
for (idle = 0; idle < 400000; idle++) {
for (idle = 0; idle < 400000000; idle++) {
if (idle & 1023) {
continue; /* cheap spin; poll every 1024 */
}
if (rxf >= 5) {
break; /* enough frames: stop early */
}
for (i = 0; i < 8; i++) {
unsigned int buf, len;
if (r16(RXR + i * 64 + 16) & RMD1_OWN) {
@@ -341,14 +348,17 @@ int main()
len = r16(RXR + i * 64 + 48) & 0x0FFF;
if (len > 0) {
rxf = rxf + 1;
lastet = (rb(buf + 12) << 8) | rb(buf + 13);
m0 = (rb(buf + 6) << 8) | rb(buf + 7);
m1 = (rb(buf + 8) << 8) | rb(buf + 9);
m2 = (rb(buf + 10) << 8) | rb(buf + 11);
/* buf is a BYTE address, rb() takes a BIT address */
lastet = (rb((buf + 12) * 8) << 8) | rb((buf + 13) * 8);
m0 = (rb((buf + 6) * 8) << 8) | rb((buf + 7) * 8);
m1 = (rb((buf + 8) * 8) << 8) | rb((buf + 9) * 8);
m2 = (rb((buf + 10) * 8) << 8) | rb((buf + 11) * 8);
}
/* re-arm this descriptor */
w16(RXR + i * 64 + 16, RMD1_OWN | ((buf >> 16) & 0xFF));
}
/* publish live counters so a dump *during* the listen window is useful */
cell(0x160, txd); cell(0x180, rxf); cell(0x200, lastet); cell(0x220, m0);
}
dot(4);
@@ -392,5 +402,9 @@ int main()
}
draw_text(104, 814, lab_rest); hex4(312, 814, csr0res);
}
/* stay here: the panel and the marker cells stay valid for inspection
* (a returned payload lets the OS reuse this memory) */
for (;;) {
}
return 0;
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""sendraw.py -- send raw Ethernet frames from the host.
Used to exercise the VT1200 payload's receive path without a protocol stack:
the payload counts frames and records the last ethertype and source MAC on its
panel, so a single broadcast frame is enough to prove RX works.
# MAME: the bridge floods to the enslaved tap
sudo python3 harness/udpstream/sendraw.py --iface vt1200 --count 3
# real unit: the host's own NIC
sudo python3 harness/udpstream/sendraw.py --iface eth0 --dst ff:ff:ff:ff:ff:ff
Options default to a 60-byte broadcast frame with ethertype 0x88B5, which is the
one the payload's panel shows in its ET row.
"""
import argparse
import socket
import struct
import sys
import time
def parse_mac(s):
parts = s.split(":")
if len(parts) != 6:
raise argparse.ArgumentTypeError("expected aa:bb:cc:dd:ee:ff")
return bytes(int(p, 16) for p in parts)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--iface", required=True, help="interface to send on")
ap.add_argument("--dst", type=parse_mac, default=parse_mac("ff:ff:ff:ff:ff:ff"),
help="destination MAC (default broadcast)")
ap.add_argument("--src", type=parse_mac, default=None,
help="source MAC (default: the interface's own)")
ap.add_argument("--ethertype", type=lambda v: int(v, 0), default=0x88B5)
ap.add_argument("--size", type=int, default=60, help="frame size from dst onwards")
ap.add_argument("--count", type=int, default=1)
ap.add_argument("--gap", type=float, default=0.2, help="seconds between frames")
args = ap.parse_args()
src = args.src
if src is None:
with open("/sys/class/net/%s/address" % args.iface) as f:
src = parse_mac(f.read().strip())
size = max(args.size, 14)
body = bytes((i & 0xFF) for i in range(size - 14))
frame = args.dst + src + struct.pack(">H", args.ethertype) + body
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW)
s.bind((args.iface, 0))
for i in range(args.count):
s.send(frame)
print("sent %d B on %s: %s -> %s ethertype 0x%04x"
% (len(frame), args.iface, src.hex(":"), args.dst.hex(":"), args.ethertype))
time.sleep(args.gap)
return 0
if __name__ == "__main__":
sys.exit(main())
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# test-bcast.sh -- run the raw-Ethernet broadcast payload in MAME and report
# what it sent and received.
#
# harness/udpstream/test-bcast.sh [payload.coff]
#
# Everything the earlier manual runs got wrong is handled here:
# * stale MAME is killed with `pkill -x` (a `pkill -f` pattern also matches
# this script's own command line and kills the test itself)
# * the network interface choice lives in harness/mame/cfg-net -- an empty
# cfg dir means MAME never opens a tap, so the LANCE sees no carrier and
# reports TMD3_LCAR
# * the host's tap only has carrier once MAME has attached it, so the script
# waits for LOWER_UP before sending anything
# * host frames are sent while the payload is inside its listen window
#
# Exit status 0 only if the payload reported transmitting and receiving.
set -uo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
M=${MAME:-/home/arti/code/k-space/mame0289/vt1200}
COFF=${1:-$ROOT/harness/udpstream/build/lance_bcast.coff}
RUN=${RUN:-/tmp/vt1200-bcast}
MARK=0x018D0000 # must match #define MARK in the payload
LOADER_AT=${LOADER_AT:-400}
LOADER_END=${LOADER_END:-2500}
DUMP_AT=${DUMP_AT:-1200} # payload now holds after its panel, so any later frame works
HOST_FRAMES=${HOST_FRAMES:-60}
[ -x "$M" ] || { echo "no MAME binary at $M (set MAME=...)" >&2; exit 1; }
[ -f "$COFF" ] || { echo "no payload at $COFF" >&2; exit 1; }
echo "== setting up =="
pkill -9 -x vt1200 2>/dev/null
sleep 2
sudo -n ip link set vt1200 up 2>/dev/null
rm -rf "$RUN"
mkdir -p "$RUN/cfg" "$RUN/nvram"
cp "$ROOT/harness/mame/cfg-net/vt1200.cfg" "$RUN/cfg/" 2>/dev/null \
|| { echo "missing harness/mame/cfg-net/vt1200.cfg (the taptun selection)" >&2; exit 1; }
TAP=$(ip -o link show 2>/dev/null | sed -n 's/^[0-9]*: \(tap-mess-[^:]*\):.*/\1/p' | head -1)
[ -n "$TAP" ] || { echo "no tap-mess-* interface -- start MAME once so it exists" >&2; exit 1; }
echo " tap: $TAP"
CAP="$RUN/frames.cap"
sudo -n timeout 70 tcpdump -i "$TAP" -e -n ether proto 0x88b5 > "$CAP" 2>&1 &
VT1200_SKIP_LEVEL1=1 \
LOADER_AT=$LOADER_AT LOADER_END=$LOADER_END \
LOADER_BIN="$ROOT/dwt/loader/build/loader.bin" \
LOADER_COFF="$COFF" LOADER_OUT="$RUN" \
VT1200_DUMP_RAM=$DUMP_AT VT1200_DUMP_DIR="$RUN" \
timeout 110 "$M" vt1200 \
-rompath "$ROOT/harness/mame/roms" -video none -sound none -skip_gameinfo \
-networkprovider taptun -nvram_directory "$RUN/nvram" -cfg_directory "$RUN/cfg" \
-debug -debugger none -autoboot_script /tmp/opencode/loader_probe_l3.lua \
-autoboot_delay 0 > "$RUN/mame.log" 2>&1 &
echo "== waiting for link =="
for i in $(seq 1 60); do
if ip -o link show "$TAP" 2>/dev/null | grep -q LOWER_UP; then
echo " carrier up after ${i}s"
break
fi
sleep 1
done
ip -o link show "$TAP" 2>/dev/null | grep -q LOWER_UP \
|| echo " WARNING: no carrier on $TAP -- the chip will report LCAR"
echo "== waiting for the payload to start (frame $LOADER_AT ~ $((LOADER_AT / 60))s) =="
sleep $((LOADER_AT / 60 + 2))
echo "== sending $HOST_FRAMES host broadcast frames =="
for i in $(seq 1 "$HOST_FRAMES"); do
sudo -n python3 "$ROOT/harness/udpstream/sendraw.py" --iface vt1200 --count 1 --gap 0 >/dev/null 2>&1
sleep 0.25
done
echo "== waiting for the payload to finish =="
sleep 8
pkill -9 -x vt1200 2>/dev/null
echo "== results =="
DUMP=$(ls -1 "$RUN"/vt1200-ram-*.bin 2>/dev/null | head -1)
if [ -n "$DUMP" ]; then
python3 - "$DUMP" "$MARK" <<'PY'
import struct, sys
d = open(sys.argv[1], 'rb').read()
base = int(sys.argv[2], 16) >> 3
def cell(bitoff): # cell() offsets are BIT offsets in the payload
o = base + (bitoff >> 3)
return struct.unpack("<I", d[o: o + 4])[0]
txd, rxf, ok, et, m0 = cell(0x160), cell(0x180), cell(0x1A0), cell(0x200), cell(0x220)
print(" TXD (frames transmitted) = %d" % txd)
print(" RXF (frames received) = %d" % rxf)
print(" last ethertype = 0x%04X" % et)
print(" last source MAC hi word = 0x%04X" % m0)
print(" ok = %d" % ok)
unwritten = (txd == 0x55555555 or rxf == 0x55555555)
if unwritten:
print(" markers not written (dump landed outside the run)")
sys.exit(0 if (not unwritten and txd > 0 and rxf > 0) else 1)
PY
RC=$?
else
echo " no RAM dump in $RUN"
RC=1
fi
CAPN=$(grep -c 88b5 "$CAP" 2>/dev/null)
[ -n "$CAPN" ] || CAPN=0
echo " payload broadcasts seen on $TAP: $CAPN"
[ "$CAPN" -gt 0 ] || RC=1
echo
if [ "$RC" = 0 ]; then echo "== PASS =="; else echo "== FAIL =="; fi
echo " log: $RUN/mame.log"
echo " pcap: $CAP"
exit $RC