Files
vt1200/harness/mame/NETWORK_REPORT.md
T
arti 0563cb08e8 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.
2026-10-30 16:57:15 +02:00

4698 lines
200 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VT1200 networking — how the firmware drives the LANCE
Reverse engineering notes, **measurement and disassembly only**. The ROM is
untouched (`md5 b46a3e442ccb86134d2356bd70d22fd2`), no driver edits. Evidence
for each claim is the command/address quoted next to it.
Address convention: **bit addresses** (TMS34010). Ghidra address = bit / 8, and
in the Ghidra project the ROM is loaded at `0x07E40000 + file offset`.
---
## 1. Hardware surface
| item | address | evidence |
|---|---|---|
| LANCE register address port (RAP) | bit `0x05900000` | `movi 0x5900000` in the only code that uses the immediate, the ISR at `0x3FDD8E80` |
| LANCE register data port (RDP) | bit `0x05800000` | same site |
| Ethernet address ROM | bit `0x06800000`, **8 bytes** = MAC + 2 checksum bytes | reader `0x3FDD74B0`; `LANCE_REPORT.md` |
| interrupt | LANCE `intr_out` → INT1 | `LANCE_REPORT.md`; `INTENB` bit 1 at `0x3FA8C340` |
Registers are 16-bit (`move …,0`). `csr_w(reg, v)` = write `reg` to RAP, then
`v` to RDP; `csr_r(reg)` = write `reg` to RAP, read RDP.
## 2. The driver's device struct (found in a live RAM dump)
The driver does **not** hard-code the register addresses in its normal paths; it
keeps them in a struct. On a live boot (RAM dump at frame 5500) the struct at
**bit `0x23CBF0`** (byte `0x4797E`) reads:
```
+0x00 u32 0x05900000 RAP base
+0x04 u32 0x05900000 RAP base (copy)
+0x08 u32 0x05800000 RDP base
+0x0C u32 0x05800000 RDP base (copy)
+0x10 u32 0x06800060 Ethernet ROM pointer (0x6800000 + 8 bytes read)
+0x14 u32 0x0008FFC9 driver status
+0x16 u8[6] 08 00 2b 16 4b 5a PADR (MAC, copied from the ROM)
```
How it was found: a byte search of the RAM dump for the little-endian values
`0x05900000` / `0x05800000` returns byte offsets `0x4797E` / `0x47986`
(= bits `0x23CBF0` / `0x23CC30`), and the MAC appears at `0x47996`
(= struct + 0x16).
This is also why a static search for `movi 0x5900000` finds only two sites: the
normal driver loads the pointer from this struct.
## 3. Initialisation sequence (from the firmware's own code)
`io5800_5900_init` at bit `0x3FDDCBE0` (Ghidra `0x07FBB97C`): loads the RAP/RDP
immediates, then:
```
RAP=1 RDP=initblock>>0 ; CSR1
RAP=2 RDP=initblock>>16 ; CSR2
RAP=3 RDP=0 ; CSR3 (bus config)
RAP=0 RDP=0x7F41 ; CSR0 = INIT + standard interrupt enables
... poll CSR0 bit 8 (IDON) ...
CSR0 = 0x0042 ; STRT | INEA (the OS's own start)
```
The OS's live path is the same sequence (LANCELOG in `LANCE_REPORT.md`:
`CSR1=0x0F80`, `CSR2=0`, `CSR3=0`, `CSR0=0x0001`, poll `CSR0=0x0181`, then
`CSR0=0x0042`).
## 4. Initialisation block
12 words / 24 bytes, read by the chip over DMA:
| word | field | live value |
|---|---|---|
| 0 | mode | `0x0000` (normal; our test used `0x8000` PROM / `0x0044` LOOP\|INTL) |
| 1-3 | PADR (6 B) | `08 00 2b 16 4b 5a` |
| 4-7 | LADRF (8 B) | `0000 0000 0008 0000` |
| 8 | RX ring address low 16 | `0x0A80` |
| 9 | RX ring high 8 + length in bits 13-15 | `0xE000` → length `1<<7` = **128** |
| 10 | TX ring address low 16 | `0x0E80` |
| 11 | TX ring high 8 + length | `0xA000` → length `1<<5` = **32** |
Captured live from the running unit with `peek` at bit `0x7C00` (byte `0xF80`,
the address stored in the pointer cell `0x23E7A0`):
```
00 00 08 00 2b 16 4b 5a 00 00 00 00 08 00 00 00 80 0a 00 e0 80 0e 00 a0
```
After boot the descriptors at byte `0x0A80` read `00 00 00 00 | 00 f0 | 00 00`
per entry — address 0, flags 0, length `0xF000` (= 4096). So the ring is
**re-armed dynamically** by the driver; it is not left statically owned.
## 5. Descriptors and DMA
8 bytes per descriptor:
```
+0 u16 buffer address low 16
+2 u16 flags | buffer address high 8 (OWN=0x8000, STP=0x0200, ENP=0x0100,
ERR=0x4000, ...)
+4 u16 buffer length
+6 u16 message count (RX completion)
```
Buffer length encoding (C-LANCE, `get_buf_length` in MAME's `am79c90.cpp:191`):
```
length = -s16(0xF000 | data) e.g. 64 B -> 0x0FC0, 1526 B -> 0x0A0A, 4096 -> 0xF000
```
DMA addresses are **24-bit byte addresses**: buffer = `(RMD1 & 0xFF) << 16 | RMD0`;
rings = `(word9 & 0x1FFF) << 16 | word8`.
## 6. Interrupt handling (corrected by trace)
Two different handlers exist; only one is live.
**Live path.** The trace shows the CPU being interrupted and the OS dispatching
by handler table:
```
(interrupted at 3F579870, IRQ 0)
00000F60: JA 3F7144C0
3F7144C0: MMTM SP,A0-A3,A8
3F7144E0: MOVI 3F7151F0,A0 ; handler table
3F715040: SETF 8
3F715050: A3 = *0x1E9420; A3++ ; interrupt counter
3F7150C0: A1 = *A0 ; handler from the table
3F7150D0: CALL A1 ; -> 3F9D5B40
3F9D5B40: MMTM SP,A7,A9,A11 ; LANCE handler
```
The LANCE handler `0x3F9D5B40` (803 calls in an idle-window trace):
```
a11 = *0x23CBF0 ; RAP pointer (0x05900000)
*RAP = 0 ; select CSR0
a7 = *0x23CC50 ; RDP pointer #2 (0x05800000)
a9 = *RDP ; read CSR0
btst 6, a9 ; INTR
jreq return ; not ours
a7 = *0x23CC30 ; RDP pointer #1
*RDP = a9 ^ 0x40 ; *RDP = 0x40 ; clear INTR
btst 15 .. 8 on a9 ; ERR, MISS, RINT, TINT ...
bit 10 set -> calla 0x3F9D62B0 ; RX poll
bit 9 set -> calla 0x3F9D8A50 ; TX routine
bit 8 set -> set 0x23EC90 = 1
```
**Not live.** `0x3FDD8E80` (the other CSR0 handler, reached only from the vector
stubs `0x3FA93470`/`0x3FA935F0`) has **0 hits** in a 168 MB boot trace with 200
ICMP echoes. It belongs to another path (diagnostics), not the running stack.
Note the three RAP/RDP pointer cells: `0x23CBF0` = RAP, `0x23CC30` and
`0x23CC50` = two RDP aliases (all `0x05900000` / `0x05800000` in the RAM dump).
`0x23CC50` is also the **CSR0 command shadow** the init/TX code reads and ORs
before writing.
## 7. What the traffic actually exercises
Differential trace: the same boot window traced with and without ICMP traffic
(`/tmp/opencode/no_ping.tr` vs `with_ping.tr`), PC sets diffed. The PCs that
appear **only** with traffic in the driver area are `0x3F9D90E0–0x3F9D9270`
and `0x3F9D9400` — the protocol layer (TCP/IP flag and counter handling, cells
`0x23CB70/0x23CB80/0x23ECxx/0x23EFxx`), not the chip ring handling. The ring
handling code also runs without traffic (it is the poll loop), which is why it
is not in the diff.
## 8. Independent validation of the above against real silicon
A payload (`harness/udpstream/lance.c`) built from this description was tested
in MAME loopback and reached:
```
ok=1 RMD1=0x0338 (OWN clear, STP|ENP) MCNT=0x001C (28 = 24 + 4 FCS) TMD1=0x0338
```
i.e. init block, ring addressing, descriptor format, length encoding, TX and RX
DMA are all correct for the modelled chip.
On the real unit the same payload completes `INIT → IDON → STRT` (CSR0 =
`0x1B3`, then a stable `0x33`), but:
* `TDMD` stays set and the TX descriptor is never touched (`0x8338`),
* the RX descriptors are never touched,
* one earlier run showed `MISS|ERR`, i.e. the receiver did see frames but had
no usable buffer.
This is the open question: the driver's dynamic ring arming (section 4) is not
yet reproduced, and the chip does not service a transmit demand from a
hand-built ring.
## 9. Reproduction / instrumentation
* Boot trace: `-autoboot_script` traces (`trace <file>`), e.g.
`/tmp/opencode/ping_trace.lua`, `/tmp/opencode/win_trace.lua`.
* Headless debugger note: **breakpoint `printf` actions print nothing**; use the
`trace <file>` action instead. `bpset` addresses are bit addresses.
* Register/ROM reads on the real unit: `dwt/solve.py --peek <bitaddr> --len N`.
* Ring/init-block dumps: `VT1200_DUMP_RAM=<frame>` and search the dump.
## 10. Ring arming and the TX kick (the part that matters for a takeover)
From the traffic trace and the ring-handler sites (code referencing the ring
cells `0x23E7E0` RX / `0x23E820` TX), both rings are driven the same way.
### RX arming — `0x3F9D36C0`
```
a9 = *0x23EC50 ; list of receive buffers
a7 = a9 + 0xA0 ; buffer data = entry + 20 bytes
a11 = a7 >> 3 ; BIT address -> BYTE address <-- key
a7 = *0x23EBB0 << 6 ; ring position * 64 bits (8-byte descriptors)
a12 = *0x23E7E0 + a7 ; ring base (BIT address 0x5400 = byte 0xA80)
*a12 = a11 ; buffer BYTE address (32-bit store)
setf 12; *a12(0x20) = 0xFDDA ; RMD2 = 12-bit length, -(550) => 550 bytes
*a12(0x18) = 0x80 ; byte 3 = OWN, set LAST
*0x23EBB0 = (pos + 1) & 0x7F ; 128-entry ring wrap
```
Two details our first payloads got wrong or approximated:
* the buffer address is converted with `srl 3` to a **byte** address before it
goes in the descriptor;
* the length is written with `setf 12` (a 12-bit field), value `0xFDDA` for a
550-byte buffer;
* OWN is written as a single byte `0x80` at descriptor + 3, **after** the
address and length.
### TX kick — `0x3F9D57F0`
```
a7 = a11 << 6 ; descriptor offset
a0 = *0x23E820 + a7 ; TX ring (BIT address 0x7400 = byte 0xE80)
a7 = *(a0 + 0x18) ; byte 3 (RMD1 high byte)
a7 |= 0x80 ; OWN
*(a0 + 0x18) = a7 ; own the descriptor
...
a7 = *0x23CBF0 ; RAP pointer
*a7 = 0 ; RAP = 0
a5 = *0x23CC50 ; CSR0 command shadow kept by the driver
a5 |= 0x0008 ; TDMD
*0x23CC30 = a5 ; RDP = shadow | TDMD
```
> **Correction — see section 40.** The explanation originally written here
> ("the command bits are not set-only: writing `0x0008` alone clears `STRT`") is
> **wrong**. The AM79C90 model makes `INIT/STRT/STOP/TDMD` write-1-to-set and
> `INEA` read/write, so a bare `TDMD` disables interrupts but does *not* stop the
> transmitter. The firmware's shadow exists to preserve `INEA`; and the
> real-silicon `CSR0 = 0x1BB` decode (`STRT`=1, `TDMD`=1, `TXON`=1) shows a poll
> that never ran, which is a different problem entirely.
**The transmit demand is sent as `CSR0-read-back | TDMD`.** `0x23CC50` is **not**
a shadow cell — it holds the driver's **RDP pointer** (`0x05800000`), and the kick
reads CSR0 back through it:
```
3F9D5960 move @0x23CC50,a3,1 ; a3 = RDP pointer (0x05800000)
3F9D5990 move *a3,a5,0 ; a5 = *RDP = current CSR0
3F9D59B0 or a3=8,a5 ; a5 |= TDMD
3F9D59D0 move @0x23CC30,a5,1 ; a5 = RDP pointer (second alias)
3F9D5A00 move *a13,*a5,0 ; *RDP = a5
```
So the driver reads the *live* CSR0 and ORs `TDMD`, preserving whatever
read/write bits (notably `INEA`) are currently set. Section 10 called that cell a
"shadow" and section 62 records why that is wrong and what it broke.
## 11. The live driver paths (RX poll, TX completion)
The stack is **polled**: the driver's task calls a receive poll and a transmit
completion check. Ring positions and counters are global cells:
| cell | meaning |
|---|---|
| `0x23E6A0` | free receive buffers |
| `0x23EBA0` | RX ring **consumer** position (poll reads here) |
| `0x23EBB0` | RX ring **producer** position (refill writes here, `& 0x7F`) |
| `0x23EC50` | list of receive buffers (data at entry + `0xA0` bits) |
| `0x23CB00`, `0x23CB30` | saved `a0`/`a2` across a poll with no frame |
| `0x23CB70`, `0x23CB90`, `0x23CBB0`, `0x23CBC0` | TX/RX statistics |
### RX poll — `0x3F9D62B0`, body at `0x3F9D6300`
```
a7 = *0x23EBA0 << 6 ; consumer position * 64 bits
a10 = *0x23E7E0 + a7 ; RX ring base (BIT address 0x5400)
setf 8
a6 = *(a10 + 0x18) ; descriptor byte 3 = RMD1 high byte
btst 7, a6 ; OWN still set -> no frame
jrne 0x3F9D8980 ; (saves a0/a2 to 0x23CB00/0x23CB30, returns 0)
a9 = *a10 ; buffer BYTE address (32-bit store)
jreq 0x3F9D8590 ; null buffer
a9 = (a9 << 8) >> 5 ; BYTE address -> BIT address
a9 -= 0xA0 ; back to the buffer-list entry
... link the buffer into the queue, *0x23E6A0 = *0x23E6A0 - 1 ...
btst 1, a6 ; STP
btst 0, a6 ; ENP
```
The poll returns 0 when the descriptor is still owned (the normal idle case) —
this is the loop that replaces interrupts in the live stack.
### TX completion — `0x3F9D8AF0`
```
a5 = *0x23E820 ; TX ring base (BIT address 0x7400)
a11 = position * 64 + a5
setf 8
a9 = *(a11 + 0x18) ; TMD1 high byte
btst 7, a9 ; OWN still set -> not complete
jrne 0x3F9DAEF0 ; (saves a2/a4/a6 to 0x23CB90/0x23CBC0/0x23CBB0)
a12 = *a11 ; buffer BYTE address
jreq 0x3F9DAC80
a12 = (a12 << 8) >> 5 ; BYTE -> BIT
btst 1, a9 ; STP
btst 0, a9 ; ENP
... error/status bits update the statistics cells ...
```
### How the poll is driven
The RX poll `0x3F9D62B0` and the TX routine are called **from the interrupt
handler** (`0x3F9D5B40`) on status bits 10 and 9 — 402 poll calls were counted in
an idle-window trace, driven by the broadcast/ARP traffic the terminal receives
even with no pings. So the hardware model for a payload is: ring + INEA, and
service CSR0 in the handler (or poll CSR0 yourself with INEA clear).
## 12. Handler dispatch and the observed protocol chain
`0x3F9D5B40` is a pure status dispatcher. After reading CSR0 and clearing INTR
it tests the status word bit by bit (all evidence: the disassembly of
`0x3F9D5B40`..`0x3F9D6280`):
| status bit | action |
|---|---|
| 15 ERR | error counter `0x23ED20`, call `0x3F9DAFF0(5)` |
| 14 | counter `0x23EEF0` |
| 13 | counter `0x23EF50` (skipped while `0x23ECA0` bit 2 is set) |
| 12 | counter `0x23EE80` |
| 11 MISS | counter `0x23ED20` |
| **10 RINT** | **`calla 0x3F9D62B0`** — the RX poll |
| **9 TINT** | **`calla 0x3F9D8A50`** — TX completion |
| 8 | `0x23EC90 = 1` |
So the interrupt handler never builds or sends a frame itself: it only reaps
status and calls the RX poll / TX completion. **Frame submission is done by the
protocol layer calling into the driver**, which is why the init function
`0x3F9D4BC0` (which contains the only `TDMD` write, at `0x3F9D57F0`) is the
driver's command entry — it is called with `(cmd, arg)` from the protocol side
(and from the refill code at `0x3F9D3A30`).
### Protocol entry points seen in a ping window
Trace window frames 3000-4200 with an ICMP flood; the RX poll's callees, in
order (addresses are bit addresses):
```
3F9DF160 / 3F9DF4C0 driver/task helpers
3F604780 3F606210 3F600200 3F60BD40 packet processing
3F9E0B80 3F9DEDA0 receive-queue / socket delivery
3FB2C000 3FB2C290 byte-swap (htons/htonl) + per-fragment checksum loop
3F61E490 3F61AB30 upper-layer (socket) handling
```
The TX completion routine `0x3F9D8A50` is entered 343 times in that window
(status bit 9), while the init-path kick `0x3F9D57F0` is never reached — so the
live TX is submitted from the protocol side into the driver command entry and
completed via `0x3F9D8A50`.
Open item for the next pass: pin the exact protocol->driver send call (the
argument layout of `0x3F9D4BC0` and which command value means "transmit"), and
map the per-fragment checksum loop `0x3FB2C290` (chain at struct+0x40).
## 13. Protocol -> driver transmit submission (resolved)
The driver command entry `0x3F9D4BC0` is what the protocol layer calls to hand
over a frame. Evidence: in a ping-flood window (frames 3000-4200) the trace
contains **343** entries to `0x3F9D4BC0`, all from the same call site:
```
3F9DCF00: MOVE A14,-*SP,1
3F9DCF10: MOVE A2,*A14+,1 ; last pushed -> arg2 = request/command value
3F9DCF20: MOVK 1,A5
3F9DCF30: MOVE A5,*A14+,1 ; arg1 = 1 (unit / path selector)
3F9DCF40: MOVE A11,*A14+,1 ; first pushed -> arg0 = device struct
3F9DCF50: CALLA 3F9D4BC0h
```
The wrapper just above (`0x3F9DCE40`) prepares the request at `a11 + 0x100`:
```
a7 = *0x23CCA0 -> *(a4+0x30) ; MAC / PADR copied into the request
a7 = *0x23CCC0 -> *(a4+0x50)
a10 -> *(a4+0x60)
```
so the request block is `device+0x100` with fields at `+0x30`, `+0x50`, `+0x60`.
### The callee's transmit branch
`0x3F9D4BC0` receives `(arg0 = device, arg1 = 1, arg2 = request)`:
```
a9 = arg0 ; device/ring structure
a10 = arg1 ; 1
if (a10 > 1) { print 0x3F9E1F78; ... } ; only "1" is a valid path
...
loop over the buffer chain:
a2 = *(a9 + 0x40) ; next buffer
a7 = *0x23EBF0 ; TX producer position
a7 <<= 6 ; 8-byte descriptor stride
a0 = *0x23E820 + a7 ; TX ring (BIT address 0x7400 = byte 0xE80)
a9 + 0x100 / +0x40 ... ; per-buffer fields
```
The loop arms the descriptors in the TX ring; the **TDMD kick** for that batch is
the code at `0x3F9D57F0` already documented in section 10:
```
a7 = *(a0 + 0x18) | 0x80 ; OWN the descriptor
a5 = *0x23CC50 ; a5 |= 0x0008 ; command shadow | TDMD
*0x23CC30 = a5 ; RDP = shadow | TDMD
```
So the transmit submission chain is:
```
RX poll 0x3F9D62B0
-> protocol (0x3F604780 / 0x3F606210 / 0x3F600200 / 0x3F60BD40 / 0x3F61E490 / 0x3F61AB30)
-> wrapper 0x3F9DCE40 (fills device+0x100 request: MAC at +0x30 ...)
-> driver command entry 0x3F9D4BC0(device, 1, request) [343 calls/window]
-> TX ring arming at 0x23E820 + producer 0x23EBF0
-> TDMD kick 0x3F9D57F0 = CSR0 shadow | 0x0008
-> TX completion later on TINT: handler -> 0x3F9D8A50 -> 0x3F9D8AF0
```
Open detail (recorded, not resolved): the exact byte layout of the
`device+0x100` request block beyond `+0x30`, `+0x50`, `+0x60`, and the
packet-buffer objects behind `0x3FB2C000`/`0x3FB2C290`.
## 14. The packet object and the `+0x100` control block
Disassembly of the wrapper `0x3F9DC890` (the function that calls the driver
command entry at `0x3F9DCF50`) shows that **arg2 (`a11`) is a packet object**,
not a device:
```
a9 = arg0
a10 = arg1
a11 = arg2 ; packet object
...
a7 = *(a11 + 0x90) ; packet state word
a5 = *(a11 + 0xF0) + 0x0E ; total length + 14 (Ethernet header)
a12 = a11 + 0x40
loop:
a7 = *(a12 + 0xB0) ; fragment length
a0 += a7 ; running total
a2++ ; fragment count
a12 = *a12 ; next fragment
```
Packet object fields (bit offsets from `a11`):
| offset | meaning |
|---|---|
| `+0x40` | next fragment pointer |
| `+0x90` | packet state (the wrapper checks it against 0x16) |
| `+0xB0` | fragment length |
| `+0xF0` | total packet length |
| `+0x100` | control block built by the wrapper for the driver call |
The wrapper copies into the control block:
```
*(cb + 0x20) = *(a9 + 0x20)
*(cb + 0x30) = MAC from 0x23CCA0 (PADR)
*(cb + 0x50) = *0x23CCC0
*(cb + 0x60) = a10
```
Given the LANCE init-block field order (mode, PADR, LADRF, rings), a control
block whose PADR sits at `+0x30` means the block starts `0x2E` bits (mode) before
it — i.e. the wrapper is handing the driver an **interface control block**
(mode/PADR/LADRF/ring words) together with the packet object; the driver entry
with `arg1 = 1` consumes it and, for the packet case, walks the fragment chain
and arms TX descriptors (section 13).
This is the last structural piece needed to describe the transmit path from the
protocol layer down to the ring and the `TDMD` kick; the byte-level semantics of
`cb+0x50`/`cb+0x60` (the two ring words copied from `0x23CCC0` and `a10`) and
the `0x3FB2C000`/`0x3FB2C290` pool internals remain the documented open items.
## 15. Correction: `0x3FB2C000` / `0x3FB2C290` are utilities, not a pool
Disassembly:
* **`0x3FB2C000`** — byte swap. The 16-bit form does
`sll 8 / andi 0xFF00 / srl 8 / or`; a second entry does the same with
`0xFF00FF00` / `0x00FF00FF` masks for 32-bit values. That is `htons`/`htonl`
(network byte order), which is why it appears repeatedly on packet headers in
the RX/TX traces.
* **`0x3FB2C290`** — a per-fragment loop over a structure: it takes a struct in
`a9`, a count in `a10`, rejects null/negative counts, reads `*(a9+0x40)` as
the next link and iterates with `a5 = 8` — a checksum/accumulate pass over the
fragment chain, not an allocator.
So the earlier "buffer pool" reading was wrong; the packet path is
header byte-swapping plus checksumming, consistent with the protocol chain in
section 13.
## 16. The control block is an init-block template (cb+0x50 / cb+0x60 resolved)
The two cells behind the wrapper's control block are not independent values:
```
0x23CCA0 (bit) = byte 0x47994: 08 00 2b 16 4b 5a <- PADR, 6 bytes
word read at 0x23CCA0 = 0x162b0008 <- MAC bytes 0..3
word read at 0x23CCC0 = 0x00005a4b <- MAC bytes 4..5
```
`0x23CCC0` is simply `0x23CCA0 + 4 bytes` (the last two MAC bytes), so the
wrapper's two stores reassemble the contiguous PADR:
```
MOVE *A9(0x20),*A4(0x20),0 ; 16-bit : mode -> cb + 0x20
MOVE A7=0x162b0008,*A4(0x30),1 ; 32-bit : MAC[0..3] -> cb + 0x30
MOVE A7=0x00005a4b,*A4(0x50),0 ; 16-bit : MAC[4..5] -> cb + 0x50
MOVE A10,*A4(0x60),0 ; 16-bit : -> cb + 0x60
```
The field spacing proves the block is a **LANCE initialisation block**:
| block bit offset | field | source |
|---|---|---|
| `cb+0x20` | mode (16) | `*(a9+0x20)` |
| `cb+0x30` | PADR bytes 0-3 (32) | MAC at `0x23CCA0` |
| `cb+0x50` | PADR bytes 4-5 (16) | MAC at `0x23CCC0` (= `0x23CCA0+4`) |
| `cb+0x60` | next word (LADRF[0]) | `a10` |
`mode` at `cb+0x20` and PADR starting at `cb+0x30` (16 bits later) is exactly
the init-block layout; LADRF follows at `cb+0x60`. So the wrapper hands the
driver a freshly built init block whose base is `cb+0x20`, together with the
packet object — the driver command entry then programs the chip from it and
transmits the packet.
### Protocol callees reached from the RX poll (located, partially characterised)
| entry | head of function shows |
|---|---|
| `0x3F604780` | calls `0x3FB2E040`; state cells `0x1DA4D0`, `0x1DA4E0`; index `0x1D27D0` |
| `0x3F606210` | counter `0x1DA4F0`, calls `0x3F606010` and `0x3FB2E040` |
| `0x3F600200` | large frame (`a14 += 0x9A0`), two args (`a9`,`a10`), frame-length fields at `a13+0x20/+0xB0/+0xC0` |
| `0x3F60BD40` | lock `0x3FB33870`, cell `0x95FD0` |
| `0x3F61E490` | list walk: `a7 = *(arg)`, compare/loop, depth counter `a13+0` |
| `0x3F61AB30` | cell `0x96AC0`, `*(a9+0x100)`, tests bit 1 of `*(a11+0x40)` |
These are the IP/ICMP and queue-handling entries; the table records what each
one touches at its entry so the next pass can map them field by field.
## 17. Protocol-region map and call-graph evidence
Method: from the ping-window trace (`icmpw.tr`, frames 3000-4200), every
`CALLA` line was parsed into a caller->callee edge
(`^([0-9A-F]{8}): CALLA\s+([0-9A-F]+)h`). Result: **131 unique edges, 47,440
calls**. The top edges are infrastructure, which is itself informative about how
the stack is built:
```
3F57A690 -> 3F702440 x2666 free()
3FB3FD60 -> 3F57A710 x1072
3FB41AD0 -> 3F701230 x1031 malloc()
3F616730 -> 3FB2C140 x1029 checksum/compare helper
3FB41F50 -> 3F701230 x948 malloc()
3F2BE2F0 -> 3F27A110 x858
3F27A770 -> 3FB3FA00 x858
3F2C5B10 -> 3F2798B0 x840
3F2C5B80 -> 3FB3FE90 x840
3F6249D0 -> 3FB33870 x688 lock
3F6254C0 -> 3FB338D0 x688 unlock
3F9DF550 -> 3F9DF160 x687 driver task helper
3F9DEE40 -> 3F703D90 x687
3F61ACC0 -> 3FB2C140 x686 checksum/compare helper
```
So the protocol layer is dominated by per-packet `malloc`/`free` and lock
traffic, with the checksum helper `0x3FB2C140` called twice per packet path
(`0x3F616730`, `0x3F61ACC0`).
### Region map `0x3F600000-0x3F630000`
A 24 KB disassembly of that range (8,824 lines) was scanned for small-constant
protocol dispatch (`cmpi 0x1/0x6/0x8/0x11`). Only **one** site matched:
```
3F6156B0 loop:
a7 = *a9 >> 4 ; a7 <<= 3 ; a7 += 0x1E0600
*a11++ = *(a7) ; copy a table entry
a7 = *a9++ & 0xF ; a7 <<= 3 ; a7 += 0x1E0600
*a11++ = *(a7)
*a11++ = 0x3A ; ':'
inc a10
cmpi 0x6, a10
jrlt loop
```
> **Correction (see section 25.2).** This paragraph originally called that loop
> an "ARP/route table display builder". It is not: the two tables it indexes
> (`0x1E0570`, `0x1E0600`) are literally the ASCII string `0123456789abcdef`,
> and the loop converts six bytes into `xx:xx:xx:xx:xx:xx` — a **MAC-address
> formatter**. The `arpre…`/`mods …` strings that follow are unrelated version
> headers (`in.c`, `in_pcb.c`), not table data.
The absence of literal protocol
compares in this range means the stack dispatches through tables/pointers rather
than a `switch` on the protocol byte, which is why a constant scan does not find
it.
This is the state of the protocol-layer RE: entry points, per-packet call costs,
and one concrete routine are documented; the table-driven dispatch and the
IP/ICMP state machines remain the open area.
## 18. Table-driven dispatch: the indirect calls resolved
Method: in the trace, an instruction like `call a5` / `call a11` is followed on
the next line by the callee's PC, so indirect edges can be reconstructed. The
ping-window trace yields **12 indirect edges, 3,333 calls**:
```
3F7150D0 -call a1-> 3F9D5B40 x687 IRQ dispatcher -> LANCE handler (known)
3F606830 -call a11-> 3F602DB0 x344
3F6271E0 -call a5-> 3F61FFA0 x343
3F6603A0 -call a5-> 3F615E00 x343
3F660BB0 -call a5-> 3F616090 x343
3F630090 -call a5-> 3F607C90 x343
3F6AACF0 -call a5-> 3F64DC00 x29
3F6AA8F0 -> 3F6293A0 / 3F64E150 x20
3F6271E0 -> 3F6540C0 x1
```
### The dispatch table at `0x1E3D80`
The site `0x3F660380` is the protocol dispatcher:
```
a5 = *(a13 + 0xC0) ; index
a5 <<= 6 ; 8-byte entries
a5 += 0x1E3D80 ; table
a5 = *a5 ; handler pointer
call a5
```
The table was read from a live RAM dump (bit `0x1E3D80` = byte `0x3C7B0`),
8 bytes per entry, two function pointers per entry:
| index | +0 | +4 |
|---|---|---|
| 0,1 | `0x3F658570` | `0x3F658650` |
| **2,3** | **`0x3F615E00`** | **`0x3F616090`** |
| 4..15 | `0x3F658570` | `0x3F658650` |
Entries 2/3 are exactly the `x343` indirect targets from the trace, i.e. the two
handlers used by the ping traffic. Disassembly:
* **`0x3F615E00`** — converts a 32-bit value from the packet to host order: it
takes `*(arg0+0x20)`, calls the helper `0x3F6166D0`, then shifts the result
down byte by byte until a non-zero low byte remains, stores it at
`*(arg1+0x20)`, and calls the byte-swap helper `0x3FB2C140`.
* **`0x3F616090`** — converts two values (from `arg0+0x20` and `arg1+0x20`) with
the same helper and compares them, returning 1 when equal, 0 otherwise. That
is a packet-field comparison (address/filter match).
So the "protocol dispatch" is a per-field helper table (convert + compare),
indexed by a field-type byte at `a13+0xC0`, not a protocol switch — which is why
the constant scan of section 17 found nothing. The IP/ICMP state machines call
these helpers per header field.
The second dispatch site, `0x3F630050`, uses a **device entry point**:
`a5 = *(a11 + 0x160); call a5` — the protocol calls the driver through a
function pointer stored in the device structure, which is consistent with the
submission path documented in section 13.
## 19. Dispatch index semantics (bounded) and the two-pointer entries
The dispatcher function `0x3F660280`..`0x3F6603A0` narrows the index before it
indexes the table:
```
L3F660280:
a5 = *(a13 + 0xC0) ; index
subk 17, a5
jrnc 0x3F661190 ; index >= 17 -> epilogue (mmfm sp,..; rets)
...
a5 = *(a13 + 0xC0)
a5 <<= 6 ; 8-byte entries
a5 += 0x1E3D80
a5 = *a5
call a5 ; entry.fn0
...
a5 = *(a13 + 0xC0)
a5 <<= 6
a5 += 0x1E3D80
a5 = *(a5 + 0x20)
*(a13 + 0x20) = a5 ; entry.fn1 copied out
```
Facts established from this and the trace:
* only indexes **0..16** are dispatched; `>= 17` returns immediately
(`0x3F661190` is the function epilogue `mmfm sp,a5..a13; rets 2`);
* each entry is **two pointers**: `fn0` at `+0` and `fn1` at `+0x20`;
* the two call sites seen in the trace use the two pointers of the **same**
entry: `0x3F6603A0` calls `entry[2].fn0` = `0x3F615E00` (convert) and
`0x3F660BB0` calls `entry[2].fn1` = `0x3F616090` (compare);
* entries 0,1 and 4..15 all point at `0x3F658570` / `0x3F658650`, i.e. the
table is sparse — only index 2 is populated with real work for the ping path.
That the ping path uses entry index 2, whose helpers are "convert a 32-bit value
to host order" and "compare two converted values", makes the index a **field
type code** (2 = a 32-bit address field) rather than the IP protocol number
(ICMP would be 1, TCP 6, UDP 17 — and 17 is out of range here). The table at
`0x1E3D80` is therefore a per-field codec/compare table used by the packet
handlers, not the protocol switch itself.
### Socket layer
No separate socket dispatcher was found in this range. The protocol layer hands
packets to the driver through the device entry pointer at `*(device + 0x160)`
(dispatcher site `0x3F630050`: `a5 = *(a11+0x160); call a5`), and the socket
API seen from user code (`socket`/`bind`/`sendto`/`recvfrom`/`listen`/`accept`/
`read`/`close` at `3F5C9A00`/`3F5CAA80`/`3F5D3D70`/`3F5D7D50`/`3F5CBCD0`/
`3F5CCB40`/`3F5D8E70`/`3F5E8930`) is the kernel-side entry into this same
machinery — documented from the user side in `dwt/notes/TCPIP.md` and measured
(this session: ~20 datagrams/s delivery ceiling).
## 20. Packet builders: UDP header builder, and a corrected `CMPI` encoding
> **Correction (see section 22).** This section originally claimed that
> `CMPI #8` "does not exist in the ROM" because a byte search for
> `0x0B40|reg, 0x0008` returned zero hits. That search was wrong: on the
> TMS34010 the `CMPI` immediate is stored **one's-complemented**. A known-good
> site proves it — `cmpi 0x8,a2 ; 0b42 fff7` at `0x3F9D2C80`, i.e. the immediate
> word is `~8`, not `8`. Searching for `0x0B40|reg, 0xFFF7` finds **42**
> `cmpi #8` sites. The ICMP type test is therefore a perfectly ordinary compare;
> the earlier negative result was a tooling error, and the paragraph below is
> kept only as a record of it.
### UDP header builder — `0x3F656200`
The ping-only code at `0x3F6562xx` is a transmit header builder, and it declares
its protocol explicitly:
```
*(a11+0x20) = 0
*(a11) = 0
*(a11+0x40) = 0 ; byte: version/IHL area, filled later
movb 17,*(a11+0x48) ; <<< protocol 17 = UDP
...
a5 = a12 + 8
calla 0x3FB2C000 ; byte swap (htons)
*(a11+0x50) = a8 (16-bit) ; port/checksum word
*(a11+0x60) = *(a9+0x90)
*(a11+0x80) = *(a9+0x60)
*(a11+0xa0) = *(a9+0xb0) ; 16-bit
*(a11+0xb0) = *(a9+0x80) ; 16-bit
*(a11+0xc0) = *(a11+0x50) ; 16-bit
*(a11+0xd0) = 0
```
So the stack has a per-protocol header builder that fills a packet template at
`a11` from a control block at `a9` and byte-swaps the port/checksum words through
`0x3FB2C000` (the `htons`/`htonl` helper of section 15). The ICMP reply builder
is the analogous routine for type 8 -> 0; it is the next target, and the zero
`CMPI #8` result above says its type test is value- or table-based rather than a
literal compare.
## 21. The send path above the driver wrapper, and checksum validation
### Transmit request — around `0x3F609D40`, wrapper call at `0x3F609F20`
The driver submission wrapper `0x3F9DC890` has exactly **one** caller in the
ping window (343 calls):
```
3F609F20: CALLA 3F9DC890h
```
and the code in front of it is the send request:
```
a9 = *(a13+0xC0) ; packet object
a7 = *(a9+0xF0) ; total length
if (a7 < 0x32) *(a9+0xF0) = 0x32 ; enforce 50-byte minimum (Ethernet+IP+ICMP min)
...
calla 0x3F9DC890(packet, arg1, arg2)
a8 = result
if (a8 != 0) {
(*(a13+0xFFE0)+0x260)++ ; tx error counter
(*(a13+0xFFE0)+0x240)++ ; tx error counter
log 0x3F60E6E0
}
```
So the layering above the driver is: packet object -> minimum-length clamp
(50 bytes) -> submission wrapper `0x3F9DC890` -> driver command entry
`0x3F9D4BC0` -> ring arming -> `TDMD` kick.
### Checksum validation on receive
`0x3FB2C290` (the per-fragment checksum loop of section 15) is called once per
packet from four sites in the receive path:
```
343 3F6206D0 344 3F6235F0 344 3F625B40 343 3F62FFC0
```
The callers compare the result and bump per-protocol error counters
(e.g. `0x96800` incremented when the checksum check fails), i.e. these are the
**verification** calls, not the reply builder. The reply builder's own checksum
call is the remaining target; the send path above shows where its packet would
have to go.
### Note on tooling
The send function's prologue is not present in Ghidra's listing for that region
(`listing.getInstructionAt(0x07EC13A8)` returns none even though surrounding
addresses are disassembled), so this part was read from raw disassembly of the
ROM image, which is why the function is described by address range rather than
by a named entry.
## 22. The ICMP echo path, end to end (type 8 -> 0 -> transmit)
This closes the protocol half: one ping is followed from the ICMP type dispatch
down to the `TDMD` kick. **Every** link below executes exactly **343 times** in
the ping-window trace (`icmpw.tr`, frames 3000-4200), i.e. once per echo request
— that count is the evidence that these addresses form one chain and not merely
a list of functions that happen to run.
```
3F6224E0 343 ICMP type dispatch
3F6212A0 343 type 8 handler: movb 0,*a10
3F621A30 343 reply path: adjust IP total length, per-type stats
3F622A40 343 reflect: pick interface, prepare
3F623350 343 ICMP checksum, then call the IP transmit
3F62EA00 343 IP transmit finalisation: htons + IP checksum
3F62FE00 343 header fix-up block inside the IP transmit
3F630070 343 load device tx method *(device+0x160)
3F607C90 343 device transmit method (reached by indirect call)
3F6084E0 343 driver send (0x32 clamp, builds request)
3F609F20 343 call submission wrapper
3F9DC890 343 driver submission wrapper
-> 3F9D4BC0 -> ring arm -> TDMD (sections 10, 13)
```
### 22.1 ICMP type dispatch — `0x3F6224E0`, jump table `0x3F622580`
The dispatch is a `jr` inside the IP-input function, not a call, and the field
size is already 8 bits when it is entered, so the key is the **type byte**:
```
3F6209B0 setf 8,0,0
3F6209C0 a7 = *a10 ; ICMP type byte
3F6209D0 subk 0x12,a7
3F6209E0 jrhi 0x3F6227E0 ; type > 18 -> drop
3F620A00 a7 = *(a10) << 5
3F620A20 a7 += 0x96860 ; received-per-type counter (4 bytes/entry)
3F620A50 (*a7) += 1
3F620A80 a7 = *(a10+0x08) ; ICMP code byte
3F620AA0 *(a13+0x80) = a7
3F620AC0 jr 0x3F6224E0 ; dispatch
3F6224E0 move *a10,a8,0 ; type byte again (same 8-bit field)
3F6224F0 cmpi 0x12,a8 ; type > 18 -> default
3F622510 jrhi 0x3F6227E0
3F622520 sll 0x5,a8 ; 32-bit (4-byte) entries
3F622530 addi 0x3F622580,a8
3F622560 move *a8,a8,1
3F622570 jump a8 ; 343 executions in the ping window
```
(The earlier draft of this section said the key was a 16-bit type+code word;
the trace shows the `jr` at `0x3F620AC0` carries the 8-bit field size straight
into the dispatch, so it is the type byte alone. The code byte is saved
separately at `a13+0x80`.)
The table read straight out of the ROM image (19 entries, 4 bytes each):
| type | meaning | handler |
|---|---|---|
| 0 | echo reply | `0x3F6227E0` (default/drop) |
| 3 | destination unreachable | `0x3F620AE0` |
| 4 | source quench | `0x3F620C90` |
| 5 | redirect | `0x3F621C40` |
| **8** | **echo request** | **`0x3F6212A0`** |
| 11 | time exceeded | `0x3F620B80` |
| 12 | parameter problem | `0x3F620C20` |
| 13 | timestamp request | `0x3F6212D0` |
| 15 | information request | `0x3F621440` |
| 17 | address mask request | `0x3F621730` |
| 1,2,6,7,9,10,14,16,18 | — | `0x3F6227E0` (drop) |
Reproduce the table (bit address -> ROM file offset `bit/8 - 0x7E40000`):
```sh
python3 - <<'PY'
import struct
rom=open('vt1200_v22.bin','rb').read()
base=(0x3F622580//8)-0x7E40000
for i in range(19):
print(i, hex(struct.unpack("<I", rom[base+i*4:base+i*4+4])[0]))
PY
```
### 22.2 The echo handler rewrites the type — `0x3F6212A0`
```
3F6212A0 sub a7,a7
3F6212B0 movb a7,*a10 ; <<< ICMP type := 0 (echo reply)
3F6212C0 jruc 0x3F621A30 ; shared reply path
```
Three instructions. `a10` is the transport header pointer, so this is the
`8 -> 0` rewrite that the whole investigation was looking for, and it does not
recompute anything itself — the shared reply path does.
### 22.3 Shared reply path — `0x3F621A30`
```
a7 = *(a13+0x20) ; length delta
a5 = *(a11+0x10) ; IP total length (byte 2)
a5 += a7 ; *(a11+0x10) = a5 ; adjust total length for the reply
*0x96840 += 1 ; "replies sent" counter
a3 = *a10 << 5 ; a3 += 0x96560 ; per-ICMP-type counter (4 bytes/entry)
(*a3) += 1
calla 0x3F622A40(reflect args)
```
`a11` is the IP header (byte 2 = total length), `a10` the ICMP header (type).
Two statistics tables are involved: per-type counters at `0x96560` (indexed by
type, 4 bytes per entry) and the reply counter `0x96840`.
### 22.4 Reflect/send — `0x3F622A40` (the whole `0x3F622A40`..`0x3F623170` body)
```
a9 = arg (IP header / packet)
*a13(0x40) = (*a9 << 2) - 20 ; header/length arithmetic
*(a13+0x00) = *(a9+0x80)
*(a9+0x80) = *(a9+0x60)
a10 = *0x96AC0 ; interface list head
loop: match *(a10+0x20) against the packet's interface
test bit 1 of *(*(a10+0x100)+0x40) ; interface flags (up/broadcast)
...
calla 0x3F623180 ; route/interface resolve
calla 0x3F62BF30 ; prepare output block
calla 0x3F62C4B0 ; prepare output block
3F622EA0 movi 0xFF,a11
3F622EC0 movb a11,*(a9+0x40) ; <<< TTL (byte 8) := 255
calla 0x3F623350 ; ICMP checksum, then ip transmit
```
So the reply is sent out of the interface the request arrived on: the interface
list at `0x96AC0` is walked, the interface flags word (`iface+0x100` -> `+0x40`,
bit 1) decides whether it is usable, the route is resolved, and the TTL is set
to 255 — the ordinary initial TTL of a freshly generated IP packet. All of this
is inside `0x3F622A40` (epilogue at `0x3F623150`/`0x3F623170`), not in
`ip`-output.
### 22.5 Transport checksum, then IP transmit — `0x3F623350`
```
a12 = a9 & 0xFFFFFC00 ; packet block base (1024-bit aligned)
a10 = (4-bit field at a9) << 2 ; setf 4 -> read nibble, scale by 4
a5 = *(a12+0x20) + a10 ; store ; block offset updated
a11 = a12 + (*(a12+0x20) << 3) ; transport header = block + offset
*a11(0x10) = 0 ; <<< zero the checksum field (byte 2 of ICMP)
calla 0x3FB2C290(*a9(0x10)-a10, a12) ; ones-complement sum helper (section 15)
*a11(0x10) = a8 ; <<< store the computed checksum
...
calla 0x3F62EA00 ; IP transmit finalisation
```
This is the ICMP checksum: field at **byte 2 of the transport header**, zeroed,
summed by `0x3FB2C290`, stored back. (Section 21's receive-side calls to the
same helper at `0x3F6206D0`/`0x3F6235F0`/`0x3F625B40`/`0x3F62FFC0` are the
matching *verification* on the way in; `0x3F623350` is the transmit side.)
### 22.6 IP transmit finalisation — `0x3F62EA00`
Args: `a9` = IP header, `a11` = device/interface, `a12` = packet block.
Function extent confirmed in the ROM: prologue `mmtm sp,a0,a2,a3,a5,a7,a9,a10,a11,a12,a13`
at `0x3F62EA00`, a single `rets 2` at `0x3F631B00`, so everything between —
including the fix-up block and the device call — is one function. The header
fix-up code sits at `0x3F62FEB0`:
```
3F62FEB0 a7 = *(a9+0x10) ; IP total length (byte 2)
3F62FEE0 calla 0x3FB2C000 ; htons
*(a9+0x10) = a8
3F62FED0 a7 = *(a9+0x30) ; flags + fragment offset (byte 6)
3F62FF00 calla 0x3FB2C000 ; htons
*(a9+0x30) = a8
3F62FF60 sub a7,a7 ; *(a9+0x50) = 0 ; <<< IP header checksum (byte 10) := 0
3F62FFC0 calla 0x3FB2C290(a0, a12) ; compute
3F630000 *(a9+0x50) = a8 ; <<< store IP header checksum
3F630030 push args
3F630070 a5 = *(a11+0x160) ; <<< device transmit method
3F630090 call a5 ; -> 0x3F607C90
```
Offsets are **bit** offsets, so `0x10/0x30/0x40/0x50` are bytes 2, 6, 8 and 10 —
total length, fragment word, TTL and header checksum: an ordinary IPv4 header
fix-up, byte-swapping the two network-order words and recomputing the checksum.
### 22.7 The device transmit method is a pointer in the device struct
The trace shows the dispatch literally (`icmpw.tr`, around line 3614):
```
3F630070: MOVE *A11(160h),A5,1 ; device + 0x160 = transmit method
3F630090: CALL A5
3F607C90: MMTM SP,A5,A7,A9-A11,A13 ; the method
```
`0x3F607C90` then checks a device flag word (`*(a9+0x40) & 0x41 == 0x41`),
byte-swaps its own checksum field, sets the minimum length `0x32`, and calls the
driver send `0x3F6084E0` — which is the function whose `0x32` clamp and wrapper
call were documented in section 21. The full stack is therefore:
```
application / sockets
-> ip_output 0x3F62EA00 (IP header, checksum, route)
-> device->transmit *(dev+0x160) = 0x3F607C90
-> driver send 0x3F6084E0 (request build, 0x32 clamp)
-> wrapper 0x3F9DC890
-> driver command entry 0x3F9D4BC0 (ring arm)
-> 0x3F9D57F0 CSR0 = shadow | TDMD
```
### 22.8 What this settles, and what it does not
Settled with trace + disassembly evidence:
* the ICMP type test is a **19-entry jump table on the type byte**
(`0x3F622580`), reached by `jr` with the 8-bit field size already set, and the
earlier "no `CMPI #8`" finding was an encoding error in my search (`CMPI`
immediate is one's-complemented);
* the echo request is answered by `movb 0,*(icmp+0)` in `0x3F6212A0`, then the
shared reply path adjusts the IP total length and counts the reply;
* the reply's transport checksum is computed at transport-header byte 2 with the
same helper used for receive-side verification;
* the IP transmit finalisation (`0x3F62EA00`) byte-swaps total length and the
fragment word and recomputes the IP header checksum at byte 10 (TTL 255 is set
by the reflect function `0x3F622A40`);
* the protocol layer reaches the driver through a **function pointer at
device+0x160**, not a fixed call;
* the driver's transmit path is the `shadow | TDMD` kick of section 10.
Not settled: the internal TCP state machine (the `estab`/`listen` transitions
behind `0x3F635910` and `0x3F645170`), the socket-API binding table itself (entry
points listed in section 19 and `dwt/notes/TCPIP.md`), and the routing/ARP tables
behind `0x96AC0` and `0x3F6156B0`. The hardware half — whether real silicon
services `TDMD` with the corrected command — is still unverified because the
unit is down.
The dispatch itself, and the TCP and UDP *entry* paths with their checksum,
list-walk and delivery calls, are in section 23.
### 22.9 Receive-side entry: checksum verification before the type dispatch
The receive half of the same function is visible in the trace just before the
dispatch (lines 2736-2762 of `icmpw.tr`):
```
3F6206D0 calla 0x3FB2C290 ; ICMP checksum over the received message
3F620700 a5 = a8
3F620710 jreq 0x3F6207B0 ; checksum OK -> continue
3F620720 *0x96800 += 1 ; <<< ICMP checksum-error counter
3F620790 jruc 0x3F6229A0 ; drop
3F6207B0 ...
3F6209B0 setf 8
3F6209C0 a7 = *a10 ; subk 0x12 ; jrhi drop ; type range check
3F620A20 a7 += 0x96860 ; (*a7) += 1 ; received-per-type counter
3F620AC0 jr 0x3F6224E0 ; -> the jump-table dispatch
```
So the same helper `0x3FB2C290` verifies the ICMP checksum on the way in
(counter `0x96800`, and `0x96820` for malformed/unsupported types) and computes
it on the way out (`0x3F623350`), which is a consistent ones-complement sum used
for both directions. The whole receive path — checksum verify, type counter,
type dispatch, per-type handlers, reply build — is one function spanning
`0x3F620xxx`..`0x3F622A30` (epilogue `0x3F622A10`: `mmfm sp,a3,a5,a7,a9,a10,a11,a12,a13; rets 2`);
its entry and the IP-protocol test that routes into it are both pinned in
section 23: the function is `0x3F61FFA0` (ICMP input) and it is entered through
the protocol dispatch table at `0x1E0F70`/`0xD6BE0`, keyed on the IP protocol
byte.
Counters seen so far, all in RAM:
| cell | meaning |
|---|---|
| `0x96800` | ICMP checksum errors (receive) |
| `0x96820` | malformed / unsupported ICMP |
| `0x96840` | ICMP replies sent |
| `0x96860` | received per ICMP type (4 bytes/entry, `type << 5`) |
| `0x96560` | sent per ICMP type (4 bytes/entry, `type << 5`) |
## 23. IP protocol dispatch, TCP and UDP input
This is the layer above ICMP: how the stack decides which transport handler gets
a received IP packet.
### 23.1 The dispatcher — `0x3F6270F0`
```
3F6270F0 setf 8,0,0
3F6270F0 a5 = *(a9+0x48) ; <<< IP protocol byte (byte 9)
3F627110 a5 <<= 3 ; 1 byte per table entry
3F627120 a5 += 0x1E0F70 ; protocol -> index table (RAM)
3F627150 a5 = *a5,0 ; index
3F627160 a7 = 0x170
3F627180 mpys a7,a5 ; index * 0x170 bits (46 bytes per block)
3F627190 a5 += 0xD6BE0 ; protocol block array (RAM)
3F6271C0 a5 = *(a5+0x50) ; <<< handler pointer (block byte 10)
3F6271E0 call a5
```
Two RAM structures drive it: a **256-entry byte table** at bit `0x1E0F70`
(protocol -> block index) and an array of **46-byte protocol blocks** at bit
`0xD6BE0`.
### 23.2 The table, read from a live RAM dump
From `ramdump3/vt1200-ram-5500.bin` (bit address -> byte `bit/8`):
```
table 0x1E0F70: 03 04 03 03 03 03 02 03 ... 03 01 03 ...
proto: 0 1 2 3 4 5 6 7 ... 16 17 18 ...
```
| proto | index | block (bit) | handler | |
|---|---|---|---|---|
| 1 ICMP | 4 | `0xD71A0` | `0x3F61FFA0` | ICMP input (section 22) |
| 6 TCP | 2 | `0xD6EC0` | `0x3F63A340` | TCP input |
| 17 UDP | 1 | `0xD6D50` | `0x3F6540C0` | UDP input |
| everything else (253 values) | 3 | `0xD7030` | `0x3F634600` | default / unreachable |
Block layout, verified in all four blocks (UDP block bytes 0..13 shown):
```
02 00 80 74 | 0d 00 | 11 00 | 03 00 | c0 40 65 3f
^+4 ^+6 ^+8 ^+10 handler = 0x3F6540C0
+6 = 17 = UDP (TCP 6, ICMP 1, default 255)
```
So a protocol block carries its own protocol number at byte 6 and its input
function at byte 10 — the dispatcher does not need the number once it has the
block.
### 23.3 Trace evidence
| trace | dispatches (`3F6270F0`) | ICMP `3F61FFA0` | TCP `3F63A340` | UDP `3F6540C0` | default `3F634600` |
|---|---|---|---|---|---|
| `icmpw.tr` (ping flood) | 344 | **343** | 0 | 1 | 0 |
| `rttcp2.tr` (80 TCP connects + UDP) | 56 | 0 | **44** | **12** | 0 |
56 = 44 + 12 exactly, i.e. every dispatch in the second trace is accounted for
by one of the two handlers, and the ICMP trace sends essentially all traffic to
the ICMP entry. `rttcp2.tr` was recorded in **real time** (no `-nothrottle`), so
wall-clock traffic from the host lands inside the traced frame window.
### 23.4 TCP input — `0x3F63A340` .. `0x3F643C40`
One function (prologue `mmtm sp,a0,a1,a3,a5,a7,a9,a10,a11,a12,a13`, single
`rets 2` at `0x3F643C40`). Head of the function:
```
3F63A340 mmtm sp,a0,a1,a3,a5,a7,a9,a10,a11,a12,a13
3F63A430 *0x97590 += 1 ; TCP packets received
a10 = packet ; a5 = a10 + (*(a10+0x20)<<3) ; a9 = a5
3F63A510 setf 4 ; a5 = *a9 ; 4-bit IHL
3F63A530 subk 5,a5 ; jrls ... ; IHL >= 5 words
3F63A5B0 length checks (0x7c, 0x28)
3F63A660 calla 0x3F6B0160 ; pull the header up / trim
3F63A700 *0x97630 += 1 ; TCP error counter
```
Ordered call sequence for the first TCP segment (a SYN to port 6000) taken from
`rttcp2.tr` — 8,835 traced instructions, 233 calls:
```
3F63A980 -> 3FB2C000 htons (ports)
3F63AA20 -> 3FB2C290 <<< TCP checksum verification
3F63B160 -> 3F6AC3C0 lock
3F63B360 -> 3FB44400 queue/memory helper
3F63B510 -> 3FB44400
3F63B670 -> 3FB2C140 compare helper
3F63B6F0 -> 3FB2C140 compare helper
3F63B790 -> 3FB2C000 htons
3F63B830 -> 3FB2C000 htons
3F63B9F0 -> 3F61E490 <<< TCB / connection list walk
3F63BC80 -> 3F6BE610
3F6500E0 -> ...
3F653230 -> 3F61B000 list operation
3F653320 -> 3F64BC70
3F652630 -> 3F635910 connection processing
```
Per-edge counts over all 44 segments:
```
44 3F63A980 -> 3FB2C000 htons
44 3F63AA20 -> 3FB2C290 TCP checksum
44 3F63B670 -> 3FB2C140 compare
44 3F63B6F0 -> 3FB2C140 compare
44 3F63B790 -> 3FB2C000 htons
44 3F63B830 -> 3FB2C000 htons
44 3F63B9F0 -> 3F61E490 TCB list walk
33 3F6431F0 -> 3F635910 per-connection processing
33 3F6432D0 -> 3F645170
22 3F642BF0 -> 3F6ADF20 logging
22 3F640EA0 -> 3F6C30F0
11 3F63B160 -> 3F6AC3C0 lock
11 3F63C450 -> 3F61AB30
11 3F63C760 -> 3F61BFC0
11 3F63C8C0 -> 3F64A710
11 3F63CA90 -> 3F643C50
11 3F63F780 -> 3F6448E0
```
So every segment gets ports byte-swapped and a checksum verified, is matched
against the connection table by the list walk `0x3F61E490`, and 11 of the 44
went on to the data-delivery path (`0x3F61AB30`, `0x3F61BFC0`, `0x3F6448E0`)
while 33 took the connection-state path.
### 23.5 UDP input — `0x3F6540C0` .. `0x3F655070`
Head of the function (`mmtm sp,a3,a5,a7,a9,a10,a11,a13`), and the calls it makes
for all 12 datagrams in the same trace:
```
3F654440 -> 3FB2C000 htons (ports) x12
3F6547F0 -> 3FB2C290 <<< UDP checksum x12
3F654AA0 -> 3F61E490 <<< socket/PCB list walk x12
3F654B40 -> 3F61AB30 socket delivery prep x12
3F654C60 -> 3F61EC00 deliver to socket x11
3F655000 -> 3F6ADF20 log / drop x1
```
12 datagrams, 11 delivered to a bound socket, 1 unmatched (the probe sent to a
closed port) and logged. The same checksum helper `0x3FB2C290` verifies UDP here
as it verifies ICMP and TCP — one ones-complement routine for all three.
### 23.6 Where the transport layer hands back to the driver
Both TCP and UDP delivery paths funnel into the reply/output machinery of
section 22: `0x3F61EC00`/`0x3F61B000` lead to `0x3F622A40` (reflect),
`0x3F623350` (transport checksum), `0x3F62EA00` (IP transmit finalisation) and
the device method `*(device+0x160)`. A UDP datagram in `rttcp2.tr` shows exactly
that continuation:
```
3F654440 -> 3FB2C000 htons
3F6547F0 -> 3FB2C290 UDP checksum verify
3F654AA0 -> 3F61E490 socket lookup
3F654C60 -> 3F61EC00 deliver
3F61F350 -> 3F6AC3C0 lock
3F61FA50 -> 3FB2C000 htons
3F61FE80 -> 3F622A40 <<< reply: reflect/send path
3F623080 -> 3F623350 transport checksum
3F6238C0 -> 3F62EA00 <<< IP transmit finalisation
```
So the complete receive-to-transmit loop is closed: LANCE interrupt -> RX poll ->
IP protocol dispatch -> transport input -> socket -> output -> IP transmit ->
device method -> ring -> `TDMD`.
### 23.7 Repro and one tooling trap
Read the dispatch tables from a RAM dump:
```sh
python3 - <<'PY'
import struct
d=open('vt1200-ram-5500.bin','rb').read()
tab=d[0x1E0F70>>3:(0x1E0F70>>3)+256]
for p in (1,6,17,0):
idx=tab[p]; blk=0xD6BE0+idx*0x170
h=struct.unpack("<I", d[(blk>>3)+10:(blk>>3)+14])[0]
print("proto %d idx %d block %#x handler %#x" % (p,idx,blk,h))
PY
```
Trap worth recording: a **stale MAME instance keeps the tap device** and happily
answers pings and TCP connects on `192.168.99.2`. Two runs produced traces with
zero network activity while the host was successfully connecting — the traffic
was going to an orphaned instance that was not the one being traced. Check
`pgrep -af mame0289/vt1200` before trusting a "no packets" trace.
## 24. The TCP state machine
The TCP input function of section 23.4 (`0x3F63A340`..`0x3F643C40`) contains the
state machine: one state word in the TCB, two jump tables over it, and the
transition code. All evidence below is ROM disassembly plus execution counts
from `rttcp2.tr` (44 TCP segments over 11 completed connections).
### 24.1 The state field
The state is a 16-bit word at **TCB bit offset `0x40`** (byte 8). Every write to
it in the TCP input function, with its immediate:
| site | value written | state |
|---|---|---|
| `0x3F63BE90` | `movk 1` | LISTEN |
| `0x3F63D980` | `movk 3` | SYN_RECEIVED |
| `0x3F63D4F0` | `movk 4` | ESTABLISHED |
| `0x3F63F740` | `movk 4` | ESTABLISHED (from the SYN_RECEIVED handler) |
| `0x3F6413F0` | `movk 10` | TIME_WAIT |
and the read that drives both dispatchers is the same field:
```
3F63F290 a8 = *(a11+0x40) ; state
3F63F2B0 subk 3,a8
3F63F2C0 cmpi 7,a8 ; jrhi default ; states 3..10 index the table
```
### 24.2 Two dispatch tables over states 3..10
Table A at `0x3F63F350` (reached at `0x3F63F340`), table B at `0x3F6417C0`
(reached at `0x3F6417B0`). Both are 8 entries of 4 bytes, indexed by
`state - 3`, i.e. states 3..10. Read from the ROM image:
| state | table A | table B |
|---|---|---|
| 3 SYN_RECEIVED | `0x3F63EFE0` | **`0x3F63F570`** |
| 4 ESTABLISHED | `0x3F63F060` | **`0x3F63F980`** |
| 5 CLOSE_WAIT | `0x3F63F060` | `0x3F63F980` |
| 6 FIN_WAIT_1 | `0x3F63F060` | `0x3F63F980` |
| 7 CLOSING | `0x3F63F200` | `0x3F63F980` |
| 8 LAST_ACK | `0x3F63F200` | `0x3F63F980` |
| 9 FIN_WAIT_2 | `0x3F63F060` | `0x3F63F980` |
| 10 TIME_WAIT | `0x3F63F200` | `0x3F63F980` |
States **0, 1, 2** (CLOSED, LISTEN, SYN_SENT) fall out of both tables through
`jrhi` (`state-3` is unsigned-large): table A's default is `0x3F63F450`, table
B's default is `0x3F6418C0`.
### 24.3 What actually executed
```
3F63F2C0 / 3F63F340 (table A dispatch) 0 <- never: every state seen was < 3
3F63F450 (table A default) 33
3F641730 / 3F6417B0 (table B dispatch) 33
3F6417B0 targets: 3F63F570 x11 <- SYN_RECEIVED
3F63F980 x22 <- established-family states
3F63F570 (SYN_RECEIVED handler) 11
3F63F980 (established handler) 33
```
The per-segment flow is a perfectly repeating 4-segment cycle (11 connections
× 4 = 44 segments):
```
seg 0 alloc/copy -> table-B default -> alloc/copy -> tcp-output -> alloc/copy
seg 1 A-default -> B:SYN_RECEIVED -> B:other -> B-default -> alloc -> output -> alloc
seg 2 A-default -> B:other -> ... (data, several outputs)
seg 3 A-default -> B:other
```
and the state writes inside those segments:
| segment | state write observed | count |
|---|---|---|
| 0 (SYN) | `0x3F63BE90` → state = **LISTEN** | 11 |
| 1 (handshake ACK) | `0x3F63F740` → state = **ESTABLISHED** | 11 |
| 2 (data) | none; data counter `0x3F63FA90` | 11 |
So a connection is seen going **CLOSED → LISTEN → (SYN_RECEIVED) → ESTABLISHED**,
11 times, which is exactly the number of completed TCP connections in that run.
### 24.4 The handlers
**SYN_RECEIVED — `0x3F63F570`** (state 3, the path that completes the handshake):
```
3F63F570 a7 = *(a11+0x120) ; TCB send-sequence field
3F63F590 a5 = *(a9+0x0E0) ; segment sequence field
3F63F5B0 cmp ; jrgt 0x3F643460 ; out of window -> drop
3F63F5E0 a7 = *(a9+0x0E0) ; a5 = *(a11+0x280) ; cmp ; jrgt drop
3F63F650 *0x972B0 += 1 ; SYN_RECEIVED segment counter (x11)
3F63F6F0 calla 0x3F6BD9F0
3F63F720 movk 4,a7
3F63F740 *(a11+0x40) = 4 ; <<< state := ESTABLISHED (x11)
3F63F780 calla 0x3F6448E0 ; send (ACK)
3F63F7C0 window arithmetic on *(a11+0xC0)
```
**Established family — `0x3F63F980`** (states 4,5,6,7,8,9,10):
```
3F63F980 a7 = *(a9+0x0E0) ; a5 = *(a11+0x120) ; cmp ; jrgt 0x3F6400C0
3F63F9E0 a7 = *(a9+0x50) ; jrne 0x3F640070 ; segment length/flags
3F63FA20 a7 = *(a9+0x110) ; a5 = *(a11+0x1E0) ; cmp ; jrne 0x3F640070
3F63FA90 *0x97790 += 1 ; data segment counter (x11)
3F63FB00 a5 = *(a11+0x50) ; jreq ...
```
**States below SYN_RECEIVED — `0x3F6418C0`** (table B default; the SYN path):
```
3F6418C0 btst 4,a12 ; segment flag bit 4 = ACK
3F6418D0 jreq 0x3F641DB0
3F6418E0 a7 = *(a11+0x180) ; a5 = *(a9+0xC0) ; cmp ; jrlt 0x3F641AD0
3F6419A0 a7 = *(a11+0x1A0) ; a5 = *(a9+0xE0) ; cmp ; jrlt 0x3F641AD0
...
```
### 24.5 TCB fields used by the machine
Only the fields the state code actually touches are listed; `a11` = TCB,
`a9` = parsed segment:
| TCB offset (bit) | use |
|---|---|
| `0x40` (byte 8) | state (24.1) |
| `0x50` (byte 10) | flags/queue marker (`jreq` gates in `0x3F63F980`) |
| `0xC0` (byte 24) | sequence field used in window arithmetic |
| `0x120` | send-sequence, compared against segment `+0xE0` |
| `0x180` | receive-sequence, compared against segment `+0xC0` |
| `0x1A0` | receive-sequence, compared against segment `+0xE0` |
| `0x1E0` | compared against segment `+0x110` |
| `0x280` | send-sequence bound, compared against segment `+0xE0` |
| `0x2A0` | alternative bound (min/max selection) |
| `0x2D0` | store in the SYN path |
Segment (not TCB) fields: `+0x50` length, `+0xC0` and `+0xE0` sequence words,
`+0x110` acknowledgement/sequence word.
Two 32-bit sequence spaces are maintained in the TCB and validated against the
segment before any state change — the jump to `0x3F643460` on failure is the
drop path.
### 24.6 The two helper calls seen in every segment
```
0x3F635910 "alloc/copy" 44 (from 0x3F652630, once per segment)
33 (from 0x3F6431F0), 33 (from 0x3F6489A0)
0x3F645170 "tcp output" 33 (from 0x3F6432D0), 22 (from 0x3F653A10)
```
`0x3F635910` allocates from a pool at bit `0x97870`: index from `0x97D70`,
address = `0x97870 + index*(1024+256)` bits (160-byte records), then fills
`+0x20`, `+0x30`, `+0x40` and copies 26 words to `+0x1B0`. `0x3F645170` reads
`*(tcpcb+0x100)` → `+0xC0`, then `+0x280`, `+0x120`, `+0x140`, `+0x1E0`,
`+0x2A0` and a flag byte at `+0xD0` — the send/window arithmetic of an output
segment.
### 24.7 Counters seen in this path
| cell | meaning | observed |
|---|---|---|
| `0x97590` | TCP segments received | 44 |
| `0x97630` | TCP input errors | — |
| `0x972B0` | segments handled in SYN_RECEIVED | 11 |
| `0x97790` | data segments handled in established states | 11 |
| `0x97D70` | pool index for `0x3F635910` | — |
### 24.8 Still open in TCP
The option parsing (MSS/window-scale) and the close path (`0x3F63F200`,
`0x3F6413F0` writing TIME_WAIT, never exercised by this traffic), the exact
meaning of the TCB fields beyond the sequence comparisons, and the socket
binding table behind `0x3F61E490`/`0x3F61EC00`.
## 25. Interface list, and a correction about the "ARP table"
### 25.1 The interface list at `0x96AC0`
`0x96AC0` is the head cell of the interface list. The lookup `0x3F623180`
walks it and compares the requested address against `*(iface+0x100)`:
```
3F623200 a9 = *0x96AC0
3F623230 jreq not-found
3F623240 a11 = *(a9+0x100) ; the interface's address object
3F623260 a7 = requested address
3F623280 cmp a7,a11 ; jrne next
3F6232A0 return a9 ; found
3F6232C0 a9 = *(a9+0x200) ; jrne loop ; <<< next interface
```
Read from the RAM dump (this boot ran the MAME harness, so the address is the
harness's own `192.168.99.2`; the structure is what matters):
```
iface at bit 0x7AA060:
+0x00 (byte 0) u32 0x00000002 family = AF_INET
+0x04 (byte 4) u32 0x0263A8C0 address -> C0 A8 63 02 = 192.168.99.2
+0x10 (byte 8) u32 0x00000002 family (second address)
+0x14 u32 0xFF63A8C0 broadcast -> 192.168.99.255
+0x20 u32 0x001DA570 pointer to the address/flags object
+0x28 u32 0xC0A86300 network -> 192.168.99.0
+0x2C u32 0xFFFFFF00 netmask -> 255.255.255.0
+0x200 u32 0x00000000 next interface (single-interface boot)
```
The address/flags object at `0x1DA570` is what `0x3F622A40` inspects
(`*(*(iface+0x100)+0x40)`, bit 1) before sending a reply: that bit is the
interface's "usable" flag.
### 25.2 Correction: `0x1E0570` / `0x1E0600` are a hex-digit table, not ARP data
The routine `0x3F615610` was described in section 17 as an ARP/route table
serialiser. It is a **MAC-address formatter**. The evidence is the table
contents themselves:
```
bit 0x1E0600 (byte 0x3C0C0):
30 31 32 33 34 35 36 37 38 39 61 62 63 64 65 66 "0123456789abcdef"
```
and the loop:
```
a7 = *a9 >> 4 ; high nibble
a7 <<= 3 ; a7 += 0x1E0600 ; *a11++ = *a7 ; -> hex character
a7 = *a9++ & 0xF ; low nibble
a7 <<= 3 ; a7 += 0x1E0600 ; *a11++ = *a7
*a11++ = 0x3A ; ':' separator
inc a10 ; cmpi 6,a10 ; jrlt loop
```
Six bytes, each rendered as two hex digits, separated by `:` — the terminal's
own MAC address in the form `08:00:2b:16:4b:5a`, which is exactly what the
Terminal Manager displays. The `arpre…`/`mods …`/`glev…` strings that follow in
the image are unrelated version headers (`in.c`, `in_pcb.c`).
### 25.3 ARP: pointer to section 26
The ARP cache, the resolve path and the ARP input function **were** located
afterwards — see section 26. The earlier statement here ("no ARP entry
structure was found") was wrong because the entry is 16 bytes inside a hashed
bucket, not a standalone structure, and because the fragment searched for was
the gateway address in the wrong byte order.
Consequence for the next pass: ARP is handled **below** IP, on the Ethernet
EtherType (0x0806) rather than the IP protocol byte, so it will not appear in the
dispatch table of section 23. The next thing to pin is the EtherType demux in
the receive path (`0x0800` vs `0x0806` vs others), which will also give the
`0x3F61FFA0`-equivalent entry for ARP. Note when searching for those compares
that `CMPI` stores its immediate one's-complemented (section 20), so `0x0806`
appears as `0xF7F9` and `0x0800` as `0xF7FF`.
## 26. ARP: the cache and the input path
Section 25.3 said the ARP cache was not found. That was wrong — it is found
below, and the reason the earlier searches missed it is recorded in 26.5.
### 26.1 ARP is present (BSD strings)
The ROM carries the usual BSD ARP diagnostics, which is what identifies the
implementation:
```
bit 0x3F6158D0 "arpresolve: no free entry"
bit 0x3F6159D0 "arp: ether address is broadcast for IP address ..."
bit 0x3F615C60 "...: %s." (log format used by the ARP code)
```
and the code that references them:
```
bit 0x3F610660 movi 0x3F6158D0 -> "arpresolve: no free entry"
bit 0x3F6120A0 movi 0x3F6159D0 -> broadcast-MAC warning
```
### 26.2 The ARP cache — 19 buckets × 9 entries
`0x3F614200` is the lookup/allocate routine:
```
3F614240 a7 = 0xFFFF ; *(a13) = a7 ; "best slot" seed
3F614280 a7 = *0x1E0550 ; jreq continue ; ARP lock/flag cell
3F6142C0 *0x1E0550 = a11
3F614300 calla 0x3F6FC200(0x95DB0, 0x3F60EE00) ; deferred work
L3F6143B0:
3F6143D0 a5 = *arg ; the IP address
3F6143E0 movk 19,a7
3F6143F0 modu a7,a5 ; <<< hash = IP mod 19
3F614410 a7 = a5 << 3 ; a7 += a5 ; *9
3F614430 a7 <<= 7 ; *128 => *1152 bits = 144 bytes
3F614450 a10 = 0x1DAF60 + hash*1152 ; <<< ARP cache base
L3F614620 (9-iteration walk, a10 += 0x80 bits per step):
a7 = *(a10+0x58) ; byte 11: entry state
jreq -> empty slot, use it
btst 2,a7 ; jrne next ; bit 2: entry not usable
a7 = *(a10+0x50) ; byte 10: 16-bit aging/LRU field
cmp with the running best -> remember this entry
```
So the cache is **19 buckets keyed by `IP mod 19`, each holding 9 entries of
16 bytes** (9 × 16 = 144 bytes), based at bit `0x1DAF60`.
### 26.3 The entry layout
From the allocation path:
```
3F614820 a7 = arg (pointer to the IP address)
3F614840 *a10 = *a7 ; <<< entry +0 = IP address (32-bit)
3F614850 movk 1,a5
3F614860 *(a10+0x58) = 1 ; <<< entry byte 11 = state (1 = incomplete)
3F614880 return a10
```
and from the input path (`0x3F6152A0`, `0x3F6152D0`) the state byte at `+0x58`
and the 16-bit field at `+0x50` are updated when a reply arrives. MAC storage
sits in the bytes between (the broadcast check at `0x3F6120A0` compares the
sender's 6-byte address), but the exact offset was not pinned by this pass.
### 26.4 ARP input — `0x3F6148E0`
```
3F6148E0 mmtm sp,a3,a5,a7,a9,a10,a11,a12,a13
3F614920 a9 = arg
3F614950 a7 = *(a9) ; 16-bit field (ARP opcode area)
3F614960 subk 2,a7 ; jrne ... ; <<< test for opcode 2 (reply)
3F614980 a7 = *(a9+0x80) ; jreq ...
...
3F6152A0 *(a10+0x58) = a5 ; entry state
3F6152D0 *(a10+0x50) = a5 ; entry aging field
3F6154F0 subk 2,a8 ; second opcode test (request/reply split)
3F615800 cmpi 6,a10 ; 6-byte hardware-address loop
```
The `cmpi 6` at `0x3F615800` is the Ethernet-address length loop — the point
where the 6-byte MAC is copied into the cache entry.
### 26.5 Why the earlier searches missed ARP — and what is still open
Three searches came back empty, and each is a real fact worth recording:
| search | result |
|---|---|
| `cmpi 0x806` anywhere in the ROM | **0 sites** (with the corrected one's-complement encoding) |
| word `0x00000806` in the frame-5500 RAM dump | **0 occurrences** |
| `cmpi 0x800` (IPv4 EtherType), 19 sites in ROM | **none execute** in either trace (`icmpw.tr`, `rttcp2.tr`) |
So the Ethernet demux does **not** compare the EtherType with a literal
`cmpi`. ARP input `0x3F6148E0` is therefore reached by some other classification
— a table lookup or a masked compare — and that classification (the receive-side
EtherType demux) remains **the open item** in this area. Practical note for the
next attempt: the ARP input is only exercised when a peer has to be resolved, so
a trace that starts with an empty ARP cache (fresh nvram) and pings the terminal
from a new source address is the cheapest way to catch it.
## 27. The one claim that needs real silicon, and how to test it
Everything in this report is reachable from the ROM image and from MAME traces,
with two exceptions — both about the same claim:
* **the transmit kick:** the firmware writes `CSR0 = shadow | TDMD`, never a bare
`TDMD` (section 10). MAME reproduces this: the loopback payload returns
`ok=1, rmd1=0x0300, mcnt=0x1C`.
* **the same payload on real silicon** completes `INIT → IDON → STRT`
(`CSR0 = 0x1B3`, then a stable `0x33`) but with the **bare-TDMD** kick the
transmitter never services the demand: `TDMD` stays set and the TX descriptor
stays owned (`TMD1 = 0x8338`). The corrected payload has **not** been run on
the machine yet.
Status: **deferred** — the VT1200 is not available (taken off site), so this is
not waiting on a power cycle any more, it is waiting on the hardware being back.
When it is, this is the whole procedure:
```sh
# 1. the unit answers (this is the gate — without it, stop)
ping -c1 -W2 100.103.100.103
# 2. build the payload (COFF, loaded by the existing loader)
harness/udpstream/build-lance.sh
# 3. run it through the loader, watching the marker output
# (same invocation used all session; LOADER_COFF is the payload)
# expected on success:
# ok=1 rmd1=0x0338 (OWN clear, STP|ENP) mcnt=0x001C txmd1=0x0338
# failure signature to watch for:
# csr0 keeps bit 3 (TDMD) set, txmd1 keeps bit 15 (OWN) set -> 0x8338
```
What to compare, in order:
| observation | meaning |
|---|---|
| `CSR0` bit 3 (`TDMD`) clears | the chip serviced the demand — the whole question |
| `TMD1` loses bit 15 (`OWN`) | the descriptor was consumed |
| `RMD1 = 0x0338`, `MCNT = 0x001C` | the loopback frame came back: OWN clear, STP\|ENP, 28 bytes of data including the 4-byte FCS |
If `TDMD` clears but no frame arrives, the remaining suspect is the loopback
setup rather than the kick, and `LANCE_LOOPBACK_REPORT.md` covers what MAME
does differently there (`RMD1_FRAM`).
> **Correction — see section 41.2/41.3.** `CSR0 = 0x1BB` is
> `IDON|INIT|STRT|TDMD|TXON|RXON|INTR` with `ERR` **clear**; there is no memory
> error. The anomaly is `INIT` still being set while `IDON` is set, which points
> at the initialization sequence (the datasheet wants `STOP` set before `INIT`)
> rather than at the transmit kick.
## 28. Where a received frame becomes an IP packet — and why ARP still will not run
### 28.1 The ARP dispatcher has exactly one caller
```
CALLA 0x3F6148E0 (ARP input) <- 0x3F65A790
```
`0x3F65A790` is inside `0x3F65A570`, a small dispatcher whose entry is
(see section 30.2: this dispatcher is **not** on the IPv4 receive path — it has
zero executions in every trace)
```
3F65A570 mmtm sp,a7,a9,a10,a11,a13
3F65A590 move a14,a13 ; addk 32,a14
3F65A5B0 jruc L3F65A7E0
```
and whose body selects a handler by folding constants into one register:
```
L3F65A7E0 a8 = *(a13-0x40) ; the type word argument
3F65A800 addi 0x7FDB96E2,a8 ; jreq 0x3F65A660
3F65A840 subk 2,a8 ; jreq 0x3F65A660
3F65A860 subi 0x3FE3FFF4,a8 ; jreq 0x3F65A5C0
3F65A8A0 subi 0x1C000B,a8 ; jreq 0x3F65A740 -> ARP input
default -> 0x3F65A8E0 (calls 0x3F65A0C0, returns 6)
```
This is a compare-chain on the EtherType done as a sequence of subtract-and-test
(the compiler's form for a `switch`), with **ARP as the fourth case**. The
literal 16-bit values could not be recovered reliably from the disassembler's
immediate printing (it prints `ADDI` immediates directly but `SUBI` ones
complemented in the same listing), so the mapping "case N = 0x0806" is
established structurally — by the ARP input call — rather than by reading the
constants. Recording that rather than guessing them.
### 28.2 It never executes — and neither does any ARP traffic
| trace | `0x3F65A570` (dispatcher) | `0x3F6148E0` (ARP input) |
|---|---|---|
| `icmpw.tr` (ping flood) | 0 | 0 |
| `rttcp2.tr` (TCP/UDP) | 0 | 0 |
| `arp2.tr` (fresh nvram, pings from a **new source address**) | 0 | 0 |
The last run was built to force ARP: empty nvram directory, and a second address
`192.168.99.77/24` added to the tap interface so the terminal would have to
resolve a peer it had never spoken to. 60 of 60 pings from that address were
answered — and `tcpdump -i vt1200 arp` captured **zero** ARP packets (0-byte
pcap) for the whole exchange.
Conclusion, and it is a limitation of the test bench rather than of the
firmware: **the terminal answers to the Ethernet source MAC of the request**, so
a peer that shares the tap's MAC never causes an ARP lookup. Exercising ARP
needs a second L2 peer with its own MAC — e.g. the MAME tap attached to a bridge
with a veth pair into a network namespace. That is a harness change, not a RE
question, and it is the prerequisite for finishing section 26.
### 28.3 The queue that feeds the IP task
The IP task `0x3F624980` does not look at Ethernet headers at all. It takes
already-classified packets from a queue:
```
3F624A20 a10 = *0x96AE0 ; queue head
3F624A70 a7 = *(a10+0x3E0) ; next
3F624A90 *0x96AE0 = a7 ; head advance
3F624BB0 a7 = *(a10+0x20) ; <<3 ; a7 += a10 ; buffer chain offset
3F624BF0 *(a13+0x20) = *(a7) ; first buffer
3F624C20 *(a10+0x20) += 4 ; consume 4 address units
3F624C80 *(a10+0x40) -= 4 ; remaining length
```
and the enqueue side:
```
3F6032A0 a9 = *0x96B20 ; queue count
3F6032D0 a11 = *0x96B40 ; cmp ; watermark check
3F603540 a11 = *0x96B00 ; tail
3F603570 *(a11+0x3E0) = a9 ; link
3F603590 *0x96B00 = a9 ; tail = new
3F6035F0 *0x96B20 = count + 1
3F603630 *0x95FD0 |= 4 ; post the "IP input ready" event
```
So the receive path is: LANCE RX poll -> *classification* (the EtherType
dispatcher of 28.1 and its siblings) -> enqueue at `0x96AE0`/`0x96B00` ->
IP task `0x3F624980` -> protocol dispatch `0x3F6270F0` (section 23).
The classification step itself is reached from `0x3F6A8670`, which is inside a
function that first tests a *byte* of the type word:
```
3F6A86D0 a11 = type >> 8 ; andi 0xFF
3F6A8710 cmpi 0x72,a11 ; 0x72 = 'r'
3F6A8730 jrne 0x3F6A87C0
3F6A8780 calla 0x3F662410
```
`cmpi 0x72` on a byte of the type field means the classification also handles
non-EtherType (character-coded) protocol ids, which is consistent with this
terminal running LAT/DECnet alongside IP. That is the next thing to pin: which
byte values map to which handler, and whether `0x0800` is even the discriminator
for IPv4 on this stack.
## 29. Socket binding table, and the classifier constants decoded
### 29.1 The binding lookup — `0x3F61E490` (best-match scoring)
Called once per segment/datagram by both transports:
```
rttcp2.tr: 3F63B9F0 -> 3F61E490 x44 (TCP, every segment)
3F654AA0 -> 3F61E490 x12 (UDP, every datagram)
```
The lookup walks a linked list and scores candidates — the BSD
`in_pcbconnect` pattern (score 3 = exact, less for each wildcard):
```
3F61E4F0 movk 3,a11 ; *(a13) = a11 ; score starts at 3
3F61E510 a7 = arg1 ; list head
3F61E530 a9 = *a7 ; first entry
3F61E540 cmp a7,a9 ; jreq end ; empty list
L3F61E570:
3F61E570 a11 = *(a9+0xB0) ; <<< entry byte 22 = local port
3F61E590 a7 = arg(-0xA0) ; requested port
3F61E5B0 cmp a7,a11 ; jrne next ; port must match
3F61E600 a7 = *(a9+0x90) ; <<< entry byte 18 = local address
3F61E630 a7 = arg(-0x80) ; requested local address
(0 on either side = wildcard, score--, keep as fallback)
L3F61E750:
3F61E750 a11 = *(a9+0x60) ; <<< entry byte 12 = foreign address
3F61E780 a11 = arg(-0x40) ; requested foreign address
(again 0 = wildcard, score--)
L3F61EA20:
3F61EA20 a9 = *a9,1 ; <<< entry +0 = next pointer
```
So the binding-table entry (PCB) is:
| offset (bit) | byte | field |
|---|---|---|
| `0x00` | 0 | next pointer |
| `0x60` | 12 | foreign address (0 = wildcard) |
| `0x90` | 18 | local address (0 = wildcard) |
| `0xB0` | 22 | local port |
The specificity scoring (3 for a full match, decreasing as wildcards are
accepted) is what makes a bound socket win over a wildcard listener — visible in
the code as the `inc a11` / `*(a13+0x20)` score counter.
### 29.2 Delivery to the socket — `0x3F61EC00`
```
rttcp2.tr: 3F654C60 -> 3F61EC00 x11 (11 UDP datagrams delivered)
```
```
3F61EC00 mmtm sp,a7,a9,a10,a11,a13
3F61EC50 a11 = arg
3F61EC70 setf 4 ; 4-bit field
3F61EC80 a7 = *a11 ; a9 = a7 << 2 ; packet field -> socket index
3F61ECB0 a7 = *0x1E0BF0 ; jreq ... ; debug/log gate
3F61ED50 log via 0x3F623B80 when set
3F61EDE0 a7 = arg(-0x40) ; subk 5 ; jreq 0x3F61EE70 ; special case 5
3F61EE40 *0x96500 += 1 ; unmatched-datagram counter
L3F61EE70:
3F61EE80 a7 = *(a11+0x30) ; andi 0xFFFF9FFF ; option/flag bits 0x6000
3F61EF00 a7 = *(a11+0x48) ; dsj ; byte 9 bit 7 (option flag)
3F61EF40 a7 = arg(-0x40) ; subk 5 ; jreq
3F61EF90 a7 = a9<<3 ; a7 += a11 ; a7 = *(a7)
3F61F010 subk 8,a7 ; jreq ...
3F61F070 subk 13,a7 ...
```
The socket struct fields this function reads are therefore:
| offset (bit) | byte | field |
|---|---|---|
| `0x30` | 6 | option/flag word (mask `0x6000` tested) |
| `0x48` | 9 | option byte, bit 7 via `dsj` |
| index table at `a11 + (index<<3)` | | per-socket state words, compared with **8** and **13** |
Two more counters belong to this layer: `0x96500` (unmatched datagrams) and the
state values 8 and 13 used by the delivery switch. The remaining offsets are resolved in section 31 — note that `+0x20` and
`+0x40` turn out to be stack slots, not structure fields.
### 29.3 The classifier constants, decoded from the raw immediate words
The chain at `0x3F65A7E0` is four subtract-and-test cases. The disassembler
prints `ADDI` operands directly but `SUBI` operands complemented, so both
readings were computed from the raw 32-bit immediate words:
```
ADDI imm = 0x7FDB96E2 (raw 0x96e2,0x7fdb — printed as-is)
SUBI imm = 0xC01C000B (raw 0x000b,0xc01c — printed 0x3FE3FFF4)
SUBI imm = 0xFFE3FFF4 (raw 0xfff4,0xffe3 — printed 0x001C000B)
```
| case | value compared (raw-immediate reading) | value (literal-printout reading) |
|---|---|---|
| 1 | `0x8024691E` | `0x8024691E` |
| 2 | `0x80246920` | `0x80246920` |
| 3 | `0x4040692B` | `0xC0086914` |
| 4 (the ARP branch) | `0x4024691F` | `0xC024691F` |
**Neither reading produces `0x0800` or `0x0806`**, so the classifier's argument
is not a bare EtherType: it is an internal encoded value (the caller
`0x3F6A86D0` even tests a *byte* of it against `0x72`, section 28.3). That is a
real answer to the question rather than a guess — the constants are now known
exactly, and the remaining unknown is the *encoder*, i.e. the code that builds
this value from the frame header. That code is upstream of `0x3F6A8670` and is
the next thing to read.
Attempted empirical shortcut, recorded so it is not repeated: sending crafted
ARP requests into the tap with `AF_PACKET` from a foreign MAC
(`aa:bb:cc:dd:ee:ff`) did **not** reach the guest — `tcpdump -i vt1200` showed
0 packets and the classifier never ran. Frame injection into a MAME tap needs
the bridge/veth arrangement of section 28.2, not a raw socket on the tap.
## 30. The receive path has no EtherType demux — and the TCP close path
### 30.1 The full receive chain, with one call per frame
Tracing the receive side backwards from the IP task gives this chain, and every
link executes **exactly once per received frame**:
| address | role | `icmpw.tr` | `rttcp2.tr` |
|---|---|---|---|
| `0x3F602E00` | RX entry: buffer state, re-arm | 344 | 56 |
| `0x3F9E0B80` | prepare the frame | 344 | 56 |
| `0x3F600200` | frame -> packet block (lengths 0x5EE/0x226/0x44C) | 344 | 56 |
| `0x3F603250` | enqueue + post "IP input ready" | 344 | 56 |
| `0x3F624980` | IP task | 344 | 56 |
| `0x3F6270F0` | IP **protocol** dispatch (section 23) | 344 | 56 |
344 = 343 ICMP + 1 other, and 56 = 44 TCP + 12 UDP: the counts are the number of
**received frames**, not the number of IP packets, because every frame goes
through the same five functions regardless of its EtherType. And the chain
contains **no comparison against the EtherType**:
* `0x3F600200` only works with frame lengths (`cmpi 0x5EE`, `0x226`, `0x44C`,
`0x200`, `0x400`) — it assembles the frame into a packet block, it does not
classify it;
* `0x3F602E00` checks a buffer state word (`*(buf+0x90)`) and otherwise just
re-arms and processes;
* `0x3F624980` (IP task) takes the packet from the queue and never looks at an
Ethernet header.
**Conclusion: on the IPv4 receive path the firmware does not demux by
EtherType.** Every frame is handed to the IP parser, which computes its header
from a 4-bit field at the packet start (`0x3F6200A0`, `setf 4`) without even
checking that the version nibble is 4. Non-IP frames are therefore "parsed" as
IP and rejected later. That is consistent with everything else measured: no
`cmpi 0x0806` exists anywhere in the ROM (section 26.5), and all 19 `cmpi
0x0800` sites never execute (same section).
### 30.2 What that means for the classifier at `0x3F65A570`
Section 28 called `0x3F65A570` "the EtherType dispatcher" because it can call
the ARP input. It is **not** on the IPv4 receive path:
```
0x3F65A570 executions: icmpw.tr = 0 rttcp2.tr = 0 arp2.tr = 0
```
So the four decoded case values (`0x8024691E`, `0x80246920`, `0x4040692B`,
`0x4024691F`, section 29.3) belong to a dispatcher that our traffic never
enters, and the IPv4 classification they might implement is not used for IPv4
receive at all. What remains genuinely unknown is (a) the encoder of that
argument and (b) which traffic reaches this dispatcher — the `cmpi 0x72`
(`'r'`) test on a byte of it in the caller suggests a character-coded protocol
family (LAT/DECnet), not Ethernet types.
### 30.3 The TCP close path — `0x3F63F200` and the TIME_WAIT entry
The close-side handler that table A selects for states 7 (CLOSING), 8
(LAST_ACK) and 10 (TIME_WAIT) is a teardown that hands off to a helper:
```
L3F63F200 push a11
3F63F220 calla 0x3F64C730 ; close / abort
3F63F250 a11 = a8
3F63F260 jruc 0x3F6438F0 ; common exit
```
and the TIME_WAIT entry itself:
```
L3F6413B0 a7 = *(a13+0x100) ; jreq 0x3F6418C0 ; only with a pending close
3F6413E0 movk 10,a5
3F6413F0 *(a11+0x40) = 10 ; <<< state := TIME_WAIT
3F641410 calla 0x3F64E960 ; start the 2xMSL timer
3F641460 movi 0x78,a3 ; 120
3F641490 *(a11+0x80) = a3 ; <<< TCB +0x80 = timeout value (120)
3F6414E0 calla 0x3F6BE2F0 ; schedule it
```
That assigns one of the fields section 29.2 left open:
| field | meaning |
|---|---|
| TCB `+0x80` (byte 32) | timeout/timer value; 120 when entering TIME_WAIT |
**None of this executed in any trace** — `0x3F63F200`, `0x3F63F060` (the
established-family handler of table A) and `0x3F6413F0` all have 0 hits,
because the workload never closed a connection. The close path is therefore
documented statically but unverified dynamically; forcing it needs a trace
where the peer sends FIN and then a second FIN (CLOSING) or simply idles out
(TIME_WAIT).
## 31. PCB layout confirmed; the delivery state set; a correction
### 31.1 The binding entry, field by field
Re-reading the lookup with the argument slots resolved (the caller pushes five
arguments; `-0xA0` is the first, `-0x20` the last):
| argument | meaning | compared against |
|---|---|---|
| `-0xA0` (arg1) | local port | entry `+0xB0` |
| `-0x80` (arg2) | local address | entry `+0x90` |
| `-0x60` (arg3) | foreign port | entry `+0x80` |
| `-0x40` (arg4) | foreign address | entry `+0x60` |
| `-0x20` (arg5) | flags | bit 0 tested at `0x3F61E990` |
so the PCB is:
| offset (bit) | byte | field |
|---|---|---|
| `0x00` | 0 | next pointer (`move *a9,a9` at `0x3F61EA20`) |
| `0x60` | 12 | foreign address |
| `0x80` | 16 | **foreign port** |
| `0x90` | 18 | local address |
| `0xB0` | 22 | local port |
| `0x40` | 8 | TCP state (section 24.1) |
| `0x80` | 32 | timer value, 120 in TIME_WAIT (section 30.3) — a different struct, the TCP control block |
Note `+0x80` appears in both tables because they are different objects: `0x3F61E490`
matches a **binding** entry (port/address), while the TCP control block of
section 24 is the per-connection structure. Do not conflate them.
Matching and scoring:
```
L3F61E570 if (entry.local_port != arg1) next
score = 3
if (entry.local_addr != arg2) { if (arg2 != 0) next; }
if (entry.foreign_port!= arg3) next
if (entry.foreign_addr!= arg4) next
L3F61E940 if (score > best) { best = score; winner = entry }
L3F61EA20 next: a9 = *a9
```
**Correction to section 29.1:** the offsets `+0x20` and `+0x40` listed there as
PCB fields were **stack slots** (`a13+0x20` is the caller's score word,
`a13-0x40` is an argument), not structure fields. The table above is the
verified layout.
### 31.2 The delivery function suppresses a set of values (see section 39 — not proven to be socket states)
`0x3F61EC00` reads a per-socket state word as `*(a11 + (index<<3))` and refuses
delivery when that word equals any of:
```
subk 5 ; subk 8 ; subk 13 ; subk 14 ; subk 15 ; subk 16 ; subk 17 ; subk 18
```
so the "not deliverable" set is **{5, 8, 13, 14, 15, 16, 17, 18}** — a compiler
switch on small state values. The other fields this function touches:
| offset | use |
|---|---|
| `+0x30` | option word, mask `0x6000` tested |
| `+0x48` | option byte, bit 7 via `dsj` |
| `+0x10` | 16-bit counter/sequence used later in the same function |
| `+0x20` | a second per-socket word read at `0x3F61FCF0` |
| `+0x40` | a per-socket value written at `0x3F61FCD0` |
The exact *meaning* of 5 and 8 and of the 13..18 block is not established: the
enumeration lives in the socket layer, and the only states we can name from
evidence are the TCP ones (section 24.1). What is established is the suppression
logic itself, which is what determines whether a received segment reaches a
socket.
### 31.3 The close-path trace attempt failed (recorded — superseded by section 32)
A run was made to exercise the close path: 60 X11 connections to port 6000, half
closed with FIN and half with RST, under a real-time trace window
(frames 2400-9000). Result:
```
0x3F63A340 (TCP input) 0
0x3F63F200 (close handler) 0
0x3F6413F0 (TIME_WAIT) 0
0x61FFA0 / 0x3F624980 0 <- not even the boot-time packets
```
The trace contains **no network activity at all**, i.e. the window closed before
the emulated stack processed anything: at the measured 53 fps this boot needs
more wall-clock time than the 124 s window allowed. The close path therefore
remained documented statically at that point; section 32 records the successful
rerun (bridge `vt1200` up, no stale `serve.py`) in which the TIME_WAIT block did
execute, and explains why `0x3F63F200` still did not.
## 32. The TIME_WAIT path executed on live traffic
Section 31.3 recorded a failed close-path run. This one worked, and the reason
the previous attempts failed was environmental, not architectural.
### 32.1 Run setup (two traps worth recording)
```
bridge vt1200 must be UP before the run:
sudo ip link set vt1200 up
the bootp server must not already be bound:
sudo pkill -9 -f harness/serve.py
```
Both were hit: the first attempt of this kind showed `network up=0` because the
bridge `vt1200` was **down** (an earlier test of mine had taken it down), and a
stale `serve.py` from a crashed run held UDP/69, so the fresh server died with
`OSError: [Errno 98] Address already in use` and the guest never got an address.
The guest's side was fine all along — `serve.py` logged
`bootp: request from 08:00:2b:16:4b:5a -> 192.168.99.2`.
With the bridge up and the port free: **150 of 150** X11 connections to port 6000
succeeded (half closed with FIN, half with RST).
### 32.2 The TIME_WAIT block ran
Gating a trace on the TIME_WAIT write (`0x3F6413F0` → `trace`, `0x3F641520` →
`trace off`) produced `/tmp/opencode/closeB.tr`, 53,514 instructions. The block
is present verbatim in the trace:
```
3F641410 MOVE A14,-*SP,1
3F641430 CALLA 3F64E960 ; 2xMSL timer / cleanup
3F641460 MOVI 78h,A3 ; 120
3F641480 SETF 10h,1,0
3F641490 MOVE A3,*A11(80h),0 ; <<< TCB +0x80 = 120 (confirmed executing)
3F6414C0 MOVE *A13(A0h),*A14+,1
3F6414E0 CALLA 3F6BE2F0 ; schedule the timer
3F6BE2F0 MMTM SP,A7,A9,A11,A13
```
So section 30.3's static reading is now confirmed dynamically: entering TIME_WAIT
sets the state word at `TCB+0x40`, writes the timeout value **0x78 (120)** to
`TCB+0x80`, and schedules the timer through `0x3F6BE2F0`.
### 32.3 But `0x3F63F200` did not run — and that is informative
No trace file was produced for the close handler gated on `0x3F63F200` →
`0x3F6438F0`, i.e. the close handler for states 7 (CLOSING), 8 (LAST_ACK) and 10
(TIME_WAIT) never executed, even though 150 connections were closed.
That refines the picture: a **passive close** (peer sends FIN, the terminal's
socket closes) enters TIME_WAIT through the state-4 handler at `0x3F6413B0`,
not through `0x3F63F200`. The `0x3F63F200` path is table A's handler for
states 7/8/10 — it needs a *simultaneous* close (FIN from both sides →
CLOSING), or a FIN arriving while already in LAST_ACK. Producing it requires the
client to send FIN, let the terminal send its own FIN, and then have the client
send a **second** FIN instead of ACKing — a TCP state the 150-connection test
never created.
### 32.4 Status of the three "documented but unexecuted" items
| item | state |
|---|---|
| TIME_WAIT entry `0x3F6413F0`, `TCB+0x80 = 0x78` | **confirmed on live traffic** (32.2) |
| CLOSING / LAST_ACK handler `0x3F63F200` | still static only — needs a simultaneous close |
| dispatcher `0x3F65A570` (four case values) | still static only — needs non-IP (LAT-family) traffic |
## 33. Why `0x3F63F200` never fires — answered by measurement
### 33.1 The forcing attempt
A client that completes a real X11 handshake and then drives a passive close was
run against the live guest (bridge up, no stale `serve.py`):
```
mode=lastack ok=21 err=39 server_closed=21 # valid setup, then FIN, drain to EOF
mode=race ok=30 err=30 server_closed=0 # FIN immediately after connect
```
140 connection attempts in total, in both orders, with the trace gated on
`0x3F63F200` → `0x3F6438F0`. Result: **no trace file** — the handler did not
execute once. TIME_WAIT meanwhile fires every time (section 32).
### 33.2 Which object each table is evaluated for
A trace captured around a TIME_WAIT entry (`closeB.tr`) shows both dispatchers
running during one close:
```
3F63F280 (table A index setup) 1 <- evaluated once
3F63F2C0 (table A range check) 1
3F63F450 (table A *default*) 3 <- taken, not the table
3F6416F0 (table B index setup) 3
3F641730 (table B range check) 3
3F63F980 (table B established) 3
```
So table A *is* evaluated during a close, but its subject is always in a state
**below 3** (`subk 3` then `jrhi` → default `0x3F63F450`), while table B is the
one that dispatches on states 3..10 and drives the connection:
```
table B -> 0x3F63F980 (established family) -> ... -> 0x3F6413B0 (TIME_WAIT entry)
```
The object table A looks at is therefore the **listening** socket (state 1),
not the connection. That is why `0x3F63F350`'s states-7/8/10 handler
`0x3F63F200` is never reached: the only object that would need it is a
connection, and connections are dispatched through table B.
### 33.3 Conclusion for this item
`0x3F63F200` (CLOSING/LAST_ACK/TIME_WAIT handler of table A) is **not reachable
from TCP segment input in this configuration**:
* 140 forced closes in both close orders — 0 executions;
* the table A dispatch was observed executing and taking its `state < 3`
default;
* `0x3F63F200` has 0 executions in every trace recorded in this whole
investigation (`icmpw.tr`, `rttcp2.tr`, `close.tr`, `closeB.tr`).
It belongs to a path fed by a caller we have never entered. That is a bounded
negative result, not an unfinished experiment: the work it would do (teardown of
a connection in CLOSING/LAST_ACK) is done for real connections by the table B
path ending in `0x3F6413B0`, which *is* confirmed live.
### 33.4 Two harness lessons from this run
```
* a SIGKILL of the shell wrapper leaves MAME running (the EXIT trap never runs);
always check `pgrep -af mame0289/vt1200` afterwards.
* MAME's Lua `print` is block-buffered when stdout is redirected to a file, so
"no f= lines in mame.log" does not mean the emulator did not run.
* the X11 server refuses roughly half the attempts (ok=21/60, ok=30/60), so a
close-path experiment needs a large attempt count to be meaningful.
```
## 34. Frame injection works — and it overturns the "ARP is separate" assumption
### 34.1 Injecting on the bridge (not the tap)
Sending raw frames with `AF_PACKET` **bound to the bridge `vt1200`** delivers
them to the guest, because the MAME tap is enslaved to that bridge and the
bridge floods to its port:
```
aa:bb:cc:dd:ee:ff > 08:00:2b:16:4b:5a ARP, Request who-has 192.168.99.2 tell 192.168.99.99
08:00:2b:16:4b:5a > aa:bb:cc:dd:ee:ff ARP, Reply 192.168.99.2 is-at 08:00:2b:16:4b:5a
```
So the terminal **does** answer ARP for its own address from a sender it has
never met. Section 29.3's earlier failure was only the injection point: binding
`AF_PACKET` to the tap delivers nothing, binding it to the bridge works.
### 34.2 But the ARP frame takes the IP path
A trace of one complete frame-processing pass (`0x3F602E00` → `0x3F603780`, the
function's own epilogue) for an ARP-only injection contains exactly:
```
3F602E00 frame entry
3F9E0B80 prepare frame
3F600200 frame -> packet block
3F603250 enqueue to 0x96AE0 + post "IP input ready" (0x3F60BD40)
3F603780 rets
callees: 3F600200, 3F9E0B80, 3F60BD40, 3FB33870/D0 (lock/unlock),
3F701230 (malloc), 3FB44400, 3FB41A30
```
No ARP-specific call, and `0x3F6148E0` (the ARP input identified in section 26.4)
is **not** among them. An ARP frame is queued exactly like an IP frame, which
fits section 30.1's conclusion that the receive path does not demux by
EtherType.
### 34.3 The default IP-protocol handler is a diagnostic dumper
Since an ARP frame reaches the IP protocol dispatch with a garbage protocol byte,
the interesting handler is the default one, `0x3F634600` (section 23.2):
```
3F6346B0 a7 = *(packet+0x48) ; the byte at IP offset 9
3F6346E0 *0x1E23C0 = a7 ; save it
3F634710 a5 = *(packet+0x80) ; *0x1E22D0 = a5
3F634760 a5 = *(packet+0x60) ; *0x1E2350 = a5
3F6347C0 push 0x1E22B0 / 0x1E2330 / 0x1E23B0 ; address variables
```
It copies header words into globals under `0x1E22B0..0x1E23C0` and logs — a
"last unknown packet" dump, not an ARP handler.
### 34.4 What this leaves, precisely
The ARP reply observed in 34.1 must be produced somewhere the traced pass does
not reach, i.e. **after** the frame is queued. The next step is therefore not
"find the EtherType demux" any more, but: trace the IP task `0x3F624980` for an
ARP frame (gate the trace on `0x3F624980`, stop at its epilogue) and see which
branch it takes instead of the protocol dispatch `0x3F6270F0`. Two candidates,
both consistent with the evidence so far:
* the task validates the IP header and, on failure, inspects the frame's
EtherType itself (a path not yet read), or
* the reply is built from the queued block by a routine that never appears in the
frame pass because it runs in the task's context.
Either way, section 26.4's claim that `0x3F6148E0` is *the* ARP input is now
doubtful: it is a function that parses ARP and writes cache entries, but it is
not reached by the observed reply. That claim is downgraded accordingly.
## 35. ARP is answered — but not by `0x3F6148E0`; and ARP frames are parsed as IP
### 35.1 The ARP reply is genuinely the firmware's
With frame injection working (section 34.1), an ARP request from a foreign MAC is
answered. The reply cannot come from anywhere else:
* the MAME driver has **no ARP code** (`grep -niE "\barp\b|0806" harness/mame/vt1200.cpp`
→ nothing), so the reply is not a driver stand-in;
* the host owns only `192.168.99.1` (`ip addr | grep -c 192.168.99.2` → 0), so the
Linux kernel is not answering for `.2`.
So the VT1200 firmware itself answers ARP — but not through the function this
report previously called "the ARP input".
### 35.2 Correction: `0x3F6148E0` does not run
A trace gated on `0x3F6148E0` → `0x3F615600` (its epilogue) produced **no trace
file** while four ARP requests were being answered:
```
sent 4 ARP requests only
ARP input did NOT fire
```
That is a direct contradiction of section 26.4, and it also explains the earlier
"0 executions in every trace": those traces contained no ARP traffic at all.
**Superseded by section 36**: the responder is `0x3F6119A0`, confirmed firing on
live ARP traffic.
`0x3F6148E0` is a function that manipulates ARP cache entries, but it is not the
code path that answers a request.
### 35.3 ARP frames are fed to the IP parser and mis-parsed as ICMP
The frame pass of section 34.2 already showed an ARP frame being enqueued to
`0x96AE0` like an IP frame. Tracing the IP task (`0x3F624980` → `0x3F6272C0`)
with an ARP frame shows what happens next:
```
3F6270F0 protocol dispatch
3F6271E0 CALL A5 -> 3F61FFA0 <<< the ICMP input handler
3F6206D0 ICMP checksum verification
3F6224E0 ICMP type dispatch
3F6212A0 echo-request handler (type 8: rewrites the type to 0)
```
So the ARP frame's "IP protocol byte" resolves to **1 (ICMP)** and its "type"
resolves to **8 (echo request)** — the stack then builds a reply from a frame
that is not IP at all. No such packet appears on the wire (the capture shows only
the ARP request/reply pair), so it is discarded later, most plausibly at the
checksum or route stage.
This is a real, verifiable firmware behaviour: **the receive path has no
EtherType filter (section 30.1), so ARP frames are parsed as IP and only
discarded by the IP-layer sanity checks** — while the genuine ARP answer comes
from a path that has not yet been read.
### 35.4 What to look for next
The responder builds an ARP *reply*: a frame whose EtherType at offset 12 is
`0x0806` and whose opcode at offset 20 is 2. Two concrete leads:
* the `arpresolve` family around `0x3F6105E0` and `0x3F6120A0` (the code that
references the ARP diagnostic strings) — the responder may live beside the
cache lookup rather than in the input function;
* a first-hit trace of the frame entry `0x3F602E00` with an ARP-only injection,
capturing the **first** indirect call, to see whether the frame is classified
before it reaches the queue at all (the fragment/block function `0x3F600200`
returns a value that `0x3F602E00` tests at `0x3F6031A0`; that branch has not
been read with an ARP frame in hand).
## 36. The ARP responder found: `0x3F6119A0` — and it drives the same hardware path
### 36.1 Identification, confirmed on live traffic
Gating a trace on `0x3F6119A0` while four ARP requests were injected (and
answered) produced a **3.1 MB trace** (344,861 instructions). The function is the
ARP responder: its head byte-swaps the two ARP header words
```
3F6119A0 mmtm sp,a3,a5,a7,a9,a10,a11,a12,a13
3F6119B0 a8 = *(packet+0x10) ; ARP ptype (byte 2) -> htons
3F611BD0 a8 = *(packet+0x30) ; ARP opcode (byte 6) -> htons
```
and the trace contains the request/response handling at `0x3F611E80` (the
broadcast-sender check that emits `arp: ether address is broadcast for IP …`).
### 36.2 It sends through the same driver path as IP
The ordered callees of one ARP pass:
```
3FB2C000 htons
3F942230 address compare/copy helper
3F6084E0 <<< the driver send of section 21 (called from 0x3F608450)
3F9DF6E0 task/queue helper
3F9DC890 <<< the driver submission wrapper
3F9D4BC0 <<< the driver command entry (ring arming)
```
with the call itself visible in the trace:
```
3F608410 MOVI 1DA570h,A7
3F608440 MOVE A7,*A14+,1
3F608450 CALLA 3F6084E0h ; clamp to 0x32, build request
3F6084E0 MMTM SP,A7,A9,A11,A13
```
So an ARP reply is handed to the chip exactly like an IP packet:
```
ARP input 0x3F6119A0 -> 0x3F6084E0 -> 0x3F9DC890 -> 0x3F9D4BC0
-> TX ring -> CSR0 = shadow | TDMD (section 10)
```
That matters beyond ARP: it means the `shadow | TDMD` kick is on the **only**
transmit path this stack has, for every protocol. There is no second way to get a
frame onto the wire.
### 36.3 Corrections to sections 26.4 and 35.2
| function | status |
|---|---|
| `0x3F6148E0` (called "the ARP input" in 26.4) | **0 executions** with live ARP — not the responder |
| `0x3F614200` (ARP cache lookup, section 26.2) | **0 executions** in the ARP pass |
| `0x3F6120A0` (broadcast warning site) | **0 executions** in this pass |
| `0x3F6119A0` | **fires on every ARP request** — this is the responder |
The cache lookup and the `0x3F6148E0` cache-entry writer are therefore *not* on
the reply path. The responder answers directly from the request it received,
which also explains why no ARP entry had to exist for the reply to work.
### 36.4 Still open in ARP
Where (if anywhere) the responder updates the cache: `0x3F614200` is not called,
so either it writes entries inline (the bucket arithmetic of section 26.2
inlined into `0x3F6119A0`) or this firmware keeps no learned cache and only
answers. Deciding between those needs a trace of the responder's *writes* to
`0x1DAF60 + (ip mod 19)*1152`, not more traffic.
## 37. The ARP responder does maintain the cache — 36.4 answered
Section 36.4 asked whether `0x3F6119A0` stores cache entries, since it never
calls the cache lookup `0x3F614200`. It does store them, with the bucket
arithmetic **inlined**. Two sites inside the responder reference the cache base
`0x1DAF60`:
```
bit 0x3F612640 addi 0x1DAF60,a11
bit 0x3F613300 addi 0x1DAF60,a11
```
(The literal `0x001DAF60` appears at exactly six places in the ROM:
`0x3F60EF40`, `0x3F6101C0`, `0x3F612640`, `0x3F613300`, `0x3F614460`,
`0x3F614AE0` — the middle two are inside the responder, the last two in the
lookup/entry-writer pair of section 26.)
### 37.1 The search inside the responder
```
3F612600 a7 = hash ; (IP mod 19) * 9 * 128
3F612620 a11 = a7
3F612630 a11 += 0x1DAF60 ; bucket base
3F612660 a12 = 0
L3F612670:
3F612670 a7 = *a11 ; entry+0 = cached IP
3F612680 a5 = *(a13+0x140) ; the sender IP from the request
3F6126A0 if (a7 == a5) -> found
3F6126C0 a12++
3F6126D0 a11 += 0x80 ; next entry (16 bytes)
3F6126F0 if (a12 < 9) loop
```
identical in structure to the lookup of section 26.2: 19 buckets, 9 entries of
16 bytes, keyed on the IP at entry+0.
### 37.2 The write
```
3F612760 if (a11 == 0) -> no slot (0x3F612AA0)
3F612790 movk 6,a7 ; 6-byte copy
3F6127C0 a7 = a11 + 0x20 ; entry byte 4 <<< the MAC field
3F612820 calla 0x3FB44400 ; memcpy(6)
3F612860 a7 = *(a11+0x58) ; entry byte 11 = state
3F612880 btst 1,a7 ; jrne ... ; state bit 1
3F6128A0 movk 1,a7 ; *(a13+0x200) = a7 ; flag: entry was created/updated
```
So on a request from an address already present (or the first free slot) the
responder copies the sender's **6-byte MAC into entry+4**, then updates the state
byte at entry+`0x58`.
The second site (`0x3F613300`) walks the same buckets against a *different*
address (`*(a13+0x160)`), tests bit **3** of the state byte at `+0x58`
(`0x3F613480`), and then runs the same 6-byte copy — so the responder updates
entries for both addresses involved in the exchange (sender and target).
### 37.3 Consequence
The ARP cache is maintained by two code paths:
| path | how |
|---|---|
| responder `0x3F6119A0` | inline bucket arithmetic, copies MAC into entry+4, sets entry state |
| lookup/allocator `0x3F614200` + writer `0x3F6148E0` | the pair documented in section 26, **not** called by the responder |
That also explains section 26.2's "entry state 1 = incomplete on allocation":
state bit 1 / bit 3 distinguish a freshly created entry from a completed one, and
the responder is what completes them.
Nothing here needed new traffic: the answer was in the ROM, and the earlier
`0x3F614200 == 0 hits` observation was correct but misleading — the responder
does not need that helper because it inlines the same arithmetic.
## 38. `0x3F65A570` is not an Ethernet path — bounded negative
### 38.1 The experiment
Using the bridge injection that works (section 34.1), six EtherTypes were sent to
the guest, five frames each:
```
0x6003 DECnet/LAT 0x1234 0x8035 RARP 0x0800 (bogus IP body) 0x8888 0x0001
```
The trace was gated on the dispatcher itself:
```
bpset 0x3F65A570 -> trace ; bpset 0x3F65B1B0 -> trace off
```
Result:
```
sent 0x6003 DECnet ... sent 0x0001
dispatcher did NOT fire
```
### 38.2 Conclusion
`0x3F65A570` is **not reachable from Ethernet input of any EtherType**. It is not
an EtherType demux, and its four case values
(`0x8024691E`, `0x80246920`, `0x4040692B`, `0x4024691F`, section 29.3) are not
EtherType codes. That is consistent with the one hard clue we had all along: its
caller `0x3F6A86D0` extracts a **byte** of the argument and compares it with
`0x72` (`'r'`), which is a character-coded protocol id, not a 16-bit EtherType.
The dispatcher is an **internal protocol-id demux**, fed by something inside the
firmware (most plausibly the LAT/service layer originating its own messages), not
by frames arriving from the wire. Reaching it would require the firmware itself to
originate such a message — a UI-driven action on the terminal, not something that
can be injected at L2.
### 38.3 What this changes in the report
| earlier claim | now |
|---|---|
| 28: "the EtherType dispatcher" | superseded — not an Ethernet path |
| 29.3: "encoder unresolved, maps to ARP/IPv4?" | answered: no EtherType mapping exists |
| 30.2: "belongs to a dispatcher our traffic never enters" | confirmed, and now shown to be unreachable by any EtherType |
Combined with sections 30.1 and 35.3, the picture of the receive side is
consistent and closed as far as the wire is concerned: **the firmware has no
EtherType demux at all** — frames go straight into the IP queue, IP-layer
sanitisation rejects non-IP, and the only non-IP protocol that answers (ARP) does
so from `0x3F6119A0` after the same queue, in the same way IP does.
The remaining unknown is therefore *inside* the firmware's own protocol layer
(what feeds `0x3F65A570`), not at the network interface.
## 39. The delivery suppression set never fires — and the "states" are not named
### 39.1 Measurement
In `rttcp2.tr` (44 TCP segments + 12 UDP datagrams, 11 delivered through
`0x3F61EC00`) the delivery function runs, but the state-suppression chain of
section 31.2 executes **zero** times:
| site | meaning | executions |
|---|---|---|
| `0x3F61EC00` | delivery entry | 11 |
| `0x3F61EF60` | `subk 5` compare | **0** |
| `0x3F61F010` | `subk 8` compare | **0** |
| `0x3F61F070` … `0x3F61F250` | `subk 13 … subk 18` | **0** |
| `0x3F61F300` | common exit | 11 |
So for ordinary, correctly delivered traffic none of the {5, 8, 13…18} values is
ever compared against — every delivery takes an earlier exit. Those values are
therefore *exceptional* states, not the normal states of a socket in service.
### 39.2 What that does and does not establish
* Established: the suppression set is not part of the normal data path, and a
workload with successfully delivered segments never reaches it.
* **Not** established, and previously over-claimed in section 31.2: that the word
being compared is a *socket state*. The function reads a 16-bit value from
`a11 + (index << 3)` where `index` comes from a 4-bit field at `*a11`, and the
object itself is passed as an argument by the UDP/TCP input paths. Whether
`a11` is a socket, a protocol entry or a descriptor table has not been proven,
and the values 5/8/13…18 have therefore no names — only a position in a compare
chain that our traffic never walks.
Section 31.2 is thus downgraded from "the delivery function suppresses a set of
socket states" to "the delivery function contains an unreached compare chain over
values 5, 8 and 13…18 on a per-entry word".
### 39.3 Why this is worth recording rather than chasing
Three attempts in this investigation tried to name socket-layer values from
static structure alone (this one, the classifier constants in 29.3, and the ARP
input in 26.4) and two of them produced wrong answers that later had to be
withdrawn. The pattern is consistent: **this firmware's interior enumerations
cannot be named from the ROM alone** — they need a workload that takes the
branch, or a symbol source. For the socket layer specifically, the branch is only
taken for exceptional conditions (which is exactly what 39.1 shows), so the
honest state of that item is "known to exist, unreached, unnamed".
What is *not* affected by this: every path that carries real traffic. Those are
documented with execution counts — the receive chain (344/56), the ICMP path
(343), the TCP state tables (11/22/33), ARP (`0x3F6119A0` firing on live
requests), and the single transmit sequence `0x3F6084E0 → 0x3F9DC890 →
0x3F9D4BC0 → shadow|TDMD`.
## 40. Correction: the shadow is about INEA, not STRT — section 10 was wrong
Section 10 claims that "CSR0's command bits are not set-only: writing `0x0008`
alone clears `STRT`, which stops the transmitter, and the demand is never
serviced". **The device model says otherwise**, and the claim has to be
withdrawn.
### 40.1 The source
`src/devices/machine/am79c90.cpp`, CSR0 write handler (register 0):
```c
/*
* INIT, STRT, STOP, TDMD - read/write with 1 only
* TXON, RXON, INTR, ERR - read only
* INEA - read/write
* IDON, TINT, RINT, MERR, MISS, CERR, BABL - read/clear only
*/
...
// handle STRT
if ((data & CSR0_STRT) && !(m_csr[0] & CSR0_STRT))
{
m_csr[0] |= CSR0_STRT;
m_csr[0] &= ~CSR0_STOP;
...
m_csr[0] |= CSR0_TXON;
m_transmit_poll->adjust(attotime::zero, 0, TX_POLL_PERIOD);
}
// transmit demand
if ((data & CSR0_TDMD) && !(m_csr[0] & CSR0_TDMD))
{
m_csr[0] |= CSR0_TDMD;
m_transmit_poll->adjust(attotime::zero, 0, TX_POLL_PERIOD);
}
// interrupt enable
if (!(m_csr[0] & CSR0_STOP) || type() == AM79C90)
{
if (data & CSR0_INEA) m_csr[0] |= CSR0_INEA;
else m_csr[0] &= ~CSR0_INEA;
}
```
and `transmit_poll()`:
```c
if (m_csr[0] & CSR0_TXON)
{
m_csr[0] &= ~CSR0_TDMD; // demand is cleared when the poll runs
...
```
Two facts follow directly:
1. **`STRT` is set-only.** No path in the write handler clears it when a write
omits it. Writing a bare `TDMD = 0x0008` therefore leaves `STRT`/`TXON` set,
and the demand *is* serviced (`TDMD` is cleared by the poll).
2. **`INEA` is genuinely read/write.** A bare `TDMD` write has bit 14 clear, so
it **disables interrupts**. That is the real consequence of not using the
shadow.
### 40.2 What the firmware's shadow is actually for
The ROM fact is unchanged and still exact — `0x3F9D57F0` reads the shadow cell
`0x23CC50`, ORs in `TDMD`, and writes the result:
```
a5 = *0x23CC50 ; shadow of the CSR0 read/write bits (INEA among them)
a5 |= 0x0008 ; TDMD
*0x23CC30 = a5 ; write back: shadow | TDMD
```
That is a driver preserving the **read/write** bits (`INEA`) across a command
write — not a workaround for set-only command bits. The distinction matters,
because the two explanations predict different failures.
### 40.3 Consequence for the real-silicon result
The on-machine symptom was `CSR0 = 0x1BB` with `TMD1 = 0x8338` (descriptor still
owned, `TDMD` still set). Decoding `0x1BB` against the bit definitions:
`STRT`(1) = 1, `TDMD`(3) = 1, `TXON`(4) = 1 — i.e. **the transmitter was enabled
and the demand was raised, but the poll never ran**. "Bare TDMD cleared STRT" does
not describe that state at all; it would have shown `STRT`/`TXON` clear.
So the open question on real hardware is *not* the shape of the CSR0 write (the
firmware's `shadow | TDMD` is correct and the corrected payload matches it), but:
**why did the chip not run a transmit poll with `TXON` and `TDMD` both set?** The
model cannot answer that — in the model a set `TDMD` always schedules the poll.
Candidate causes to test on the machine, in order:
1. the transmit ring words in the init block were not what the chip latched
(ring base/length encoding, `CSR1/CSR2` written while `STOP` was clear);
2. the chip latched its init block from the wrong address (the init-block pointer
only loads when `INIT` is accepted, which requires `STOP`);
3. the descriptor as written is not accepted as owned by the chip (byte order of
the `OWN` bit / the 24-bit address split).
### 40.4 Revert note
If this correction is found to be wrong, the evidence to beat is the two source
blocks quoted above (`am79c90.cpp`, CSR0 `case 0:` and `transmit_poll()`), not the
earlier prose in section 10. Section 10 now carries a pointer here.
## 41. Bare `TDMD` vs `shadow|TDMD`: identical in MAME — and what `0x1BB` really says
### 41.1 The experiment
The payload was built twice, differing only in the kick:
```
shadow: csr_w(0, CSR0_STRT | CSR0_TDMD);
bare: csr_w(0, CSR0_TDMD);
```
Both were run through the MAME loader probe on the same boot path:
| payload | csr0a | csr0b | ok | rmd1 | mcnt | txmd1 |
|---|---|---|---|---|---|---|
| shadow `STRT\|TDMD` | `0x7B3` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
| bare `TDMD` | `0x7B3` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
**Byte-for-byte identical.** Two conclusions follow, and both matter:
1. The corrected CSR0 semantics of section 40 are confirmed empirically: in the
model a bare `TDMD` works exactly as well, because `STRT` is set-only and the
demand is serviced regardless.
2. **MAME never distinguished the two payloads.** The earlier statement "MAME
verifies the corrected `shadow|TDMD` kick" is void: what those runs verify is
the ring, descriptors, length encoding and DMA — not the shape of the CSR0
write. The payload polls rather than using interrupts, so the one bit the
shadow actually protects (`INEA`) is irrelevant to this test.
That also means the real-silicon failure cannot be attributed to the kick at all,
by either model or payload experiment.
### 41.2 What `CSR0 = 0x1BB` actually decodes to
Using the model's own bit definitions (`am79c90.h`):
```
CSR0_INIT 0x0001 CSR0_STRT 0x0002 CSR0_STOP 0x0004 CSR0_TDMD 0x0008
CSR0_TXON 0x0010 CSR0_INEA 0x0040 CSR0_IDON 0x0100 CSR0_ERR 0x8000
```
`0x1BB = 0x0100 | 0x00BB` = **IDON | INIT | STRT | TDMD | TXON | RXON | INTR**,
with **`ERR` clear** and **`INEA` clear**.
The earlier note in section 27 guessed that `ERR` was set and that the poll had
failed on a DMA error. That guess was wrong — there is no error bit at all.
### 41.3 The anomaly that is now the best lead (withdrawn — see section 47.2)
`INIT` **and** `IDON` are both set, which the model treats as contradictory:
initialization sets `IDON` and clears `INIT`, and `INIT` is only re-asserted by a
new `INIT` write. With `TXON = 1`, `TDMD = 1` and no error, a chip that still
considers itself initializing is a plausible reason for the transmit poll never
running.
The datasheet requirement quoted in the model's own comment is directly relevant:
```
* The STOP bit must be set prior to setting the INIT bit.
* Setting INIT clears the STOP bit.
```
~~Our payload writes `INIT` **without first setting `STOP`**.~~ **Withdrawn — see
section 42.1**: the payload does set `STOP` at the start of `main()`. The
`INIT`+`IDON` state remains unexplained, and a STOP-immediately-before-INIT
variant (`lance_stopinit.coff`) is byte-identical in MAME (section 42.2).
### 41.4 Testable on the machine, in this order
1. read `CSR0` immediately after the `INIT` write and after the `IDON` poll, to
see whether `INIT` clears (`0x1BB` shows it did not);
2. set `STOP` (`0x0004`) before writing `INIT`, per the datasheet, then `INIT`,
then poll `IDON`, then `STRT`, then `TDMD`;
3. if `TDMD` still sticks, read `CSR1`/`CSR2` back to confirm the chip latched the
init block address the payload thinks it wrote.
Each of these is a register read the existing payload can already perform; no new
tooling is needed, only the VT1200 being available (section 27).
## 42. STOP-before-INIT gives no observable difference — and 41.3 was wrong about our payload
### 42.1 The payload already set STOP first
Section 41.3 suggested the real-chip anomaly (`INIT` still set with `IDON`) might
be because "our payload writes `INIT` without first setting `STOP`". Reading the
payload shows that is **wrong**: `main()` does
```c
csr_w(0, CSR0_STOP); /* line 131, first thing after the marker */
...
csr_w(1, INITB >> 3); csr_w(2, (INITB >> 3) >> 16); csr_w(3, 0);
csr_w(0, CSR0_INIT); /* line 186 */
```
so the datasheet order was already respected (with the whole descriptor/VRAM
setup in between). The hypothesis is withdrawn.
### 42.2 A tighter variant, and its result
The only meaningful variant left is `STOP` *immediately* before `INIT`:
```c
csr_w(3, 0);
csr_w(0, CSR0_STOP); /* added: STOP immediately before INIT */
csr_w(0, CSR0_INIT);
```
`lance_stopinit.coff` was built and run against the current payload:
| payload | csr0a | csr0b | ok | rmd1 | mcnt | txmd1 |
|---|---|---|---|---|---|---|
| `lance.coff` | `0x7B3` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
| `lance_stopinit.coff` | `0x7B3` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
Identical again. The model does not model any INIT/STOP ordering effect — which
matches its own source comment, where the ordering rule is explicitly described
as contradictory in the datasheet and not enforced:
```
* The STOP bit must be set prior to setting the INIT bit.
* ...
* This is clearly contradictory; driver code sets the STRT bit
* after INIT, so assume STRT does not require STOP to be set.
```
So neither ordering can be validated or invalidated in MAME. The
`INIT`+`IDON` state seen on real silicon is therefore still unexplained, but the
list of candidate causes has lost its "payload didn't set STOP" entry.
### 42.3 What is left as a cause
The remaining candidates all concern what the *chip* latched, not what the
payload wrote:
1. `INIT` write accepted but the chip kept `INIT` set because the init-block DMA
read did not complete as expected (the model performs it synchronously; a
real chip reports errors through `MERR`, which is *clear* here, so this is
weaker);
2. the chip re-asserting `INIT` because the payload writes `INIT` again later
(there is a retry path at `lance.c:263`), and the real chip not clearing it;
3. a real-chip `CSR0` read-back quirk that leaves `INIT` visible while idle.
None of these can be decided in MAME. They are register reads on the machine.
### 42.4 Item closed for the offline phase
`lance_stopinit.coff` exists, produces identical results, and the section 41.3
hypothesis is withdrawn. The offline phase on the hardware question is now
exhausted: every variant that MAME can distinguish has been run, and the
remaining difference is a real-chip register state that the model does not
reproduce.
## 43. The delivery word: addressing pinned, names still absent
### 43.1 How the compared word is addressed
The chain at `0x3F61EC00` reads a 16-bit word from a computed slot:
```
3F61EC70 setf 4,0,0
3F61EC80 a7 = *a11 ; 4-bit field at the object's first 4 bits
3F61EC90 a9 = a7 << 2 ; index * 2 (kept shifted for the next step)
...
3F61EF90 a7 = a9 << 3 ; (index*2) << 3 = index * 16 bits
3F61EFA0 a7 += a11 ; -> a11 + index * 2 bytes ... see note
3F61EFB0 a7 = *a7,0 ; 16-bit read -> the compared word
3F61F010 subk 8,a7
3F61F070 subk 13,a7
...
```
so the value tested against {5, 8, 13…18} is a **16-bit entry in a table at the
object, selected by a 4-bit field stored at the object's own start**, with a
2-byte stride (`index * 2`). Whatever the object is, it carries a small tag and
an array of 16-bit words — that much is now exact.
### 43.2 What the object is
The caller builds the binding query from the object's own fields and then passes
the same object to the delivery function:
```
3F654980 a8 = *(a9+0xB0) ; port-like field
3F6549B0 push *(a9+0x80)
3F6549F0 push *(a9+0xA0)
3F654A20 push *(a9+0x60)
3F654A60 push 0x96E70 ; binding list
3F654AA0 calla 0x3F61E490 ; lookup -> result in a8, saved to a10
3F654C50 push a9 ; <<< same object goes to 0x3F61EC00
```
The offsets used (`+0x60`, `+0x80`, `+0xA0`, `+0xB0`) are the PCB-style
address/port fields of section 31.1, so the object is the **per-datagram
binding structure** — but whether it is a real PCB from the binding table or a
scratch copy built by the UDP input (which does copy five words of it into its
own frame at `0x3F654BD0`) is not settled by this. Recording the distinction
instead of picking one.
### 43.3 No enumeration table exists in the ROM
A table of the exact values would have named them. Searched the whole image:
```
16-bit 5, 8, 13, 14, 15, 16, 17, 18 -> 0 occurrences
byte 5, 8, 13, 14, 15, 16, 17, 18 -> 0 occurrences
16-bit 13, 14, 15, 16, 17, 18 -> 3 occurrences, all in data regions
```
So the values are produced by *storing* them, not by indexing a table. Finding
that store statically requires alias analysis of `a11`'s provenance, which is
exactly the technique that has produced two wrong answers in this area already
(26.4, 31.2). I am not going to guess a third time.
### 43.4 The dynamic capture that should settle it
The right tool is a memory dump from a breakpoint, using the register in the
address expression:
```
bpset 0x3F61EE70,1,"dump a11,64,/tmp/opencode/obj.bin"
```
That was attempted with six UDP probes to ports 7/9/6000/37/111. No file was
produced — either the breakpoint never fired (the probes may not have reached a
delivered socket in that run) or the `dump` action's address expression was
rejected. The recipe is recorded so the next attempt can iterate on it (check the
MAME debugger console for the action's own error output, and confirm the probe
actually lands on a bound socket first).
Until that dump is captured, the values 5/8/13…18 stay **unnamed**, and section 39
remains the accurate summary of this item.
## 44. The delivery object is the PACKET, not a socket — captured on the wire
### 44.1 The dump now works (two syntax bugs fixed)
The earlier attempt failed for two reasons, both mine:
```
wrong: dump a11,64,/tmp/opencode/obj.bin ; filename must come FIRST
right: dump /tmp/opencode/obj.bin,a11,512 ; and length is in ADDRESS UNITS
```
`debugcmd.cpp` `execute_dump()` takes `params[0]` as the filename, `params[1]` as
the address and `params[2]` as the length in address units. With the TMS34010's
1-bit address unit, 64 bytes is **512** units.
With the corrected command, a breakpoint at `0x3F61EE70` (inside the delivery
function, before the compare chain) dumped 64 bytes while 24 UDP datagrams were
being delivered. The file contains **our own datagram**:
```
45 00 11 00 3f 99 00 40 40 11 00 00 c0 a8 63 01 c0 a8 63 02 ...
192.168.99.1 192.168.99.2
... 70 72 6f 62 65 2d 31 31 0a "probe-11\n"
```
version/IHL `0x45`, TTL 64, protocol **17 (UDP)**, source `192.168.99.1`,
destination `192.168.99.2`, payload `probe-11`.
### 44.2 So `a11` is the packet — and the lookup keys finally make sense
`a11` in `0x3F61EC00` points at the received packet's IP header. Re-reading the
binding lookup (`0x3F61E490`) with that in hand, the query arguments are the
datagram's own header fields:
| pushed at | reads | byte offset | meaning |
|---|---|---|---|
| `0x3F654980` | `*(a9+0xB0)` | 22 | UDP **destination** port |
| `0x3F6549B0` | `*(a9+0x80)` | 16 | IP **destination** address |
| `0x3F6549F0` | `*(a9+0xA0)` | 20 | UDP **source** port |
| `0x3F654A20` | `*(a9+0x60)` | 12 | IP **source** address |
and the lookup compares them against the PCB fields of section 31.1:
```
arg1 (packet: dport) == entry +0xB0 ; the socket's LOCAL port
arg2 (packet: dst IP) == entry +0x90 ; the socket's LOCAL address
arg3 (packet: sport) == entry +0x80 ; the socket's FOREIGN port
arg4 (packet: src IP) == entry +0x60 ; the socket's FOREIGN address
```
That is exactly a UDP demux, and it validates the PCB layout of section 31.1 from
an independent direction.
### 44.3 Corrections
| claim | status |
|---|---|
| 43.2: "the object is a per-datagram binding structure / PCB-like" | **wrong** — it is the packet buffer |
| 31.2/39: "the compared word is a per-socket state" | **wrong anchor** — the word is read out of the packet |
| 31.1: PCB layout (`+0x60` foreign addr, `+0x80` foreign port, `+0x90` local addr, `+0xB0` local port) | **confirmed** by the query/compare pairing above |
The compare chain's values (5, 8, 13…18) are therefore compared against **packet
header data indexed by a 4-bit field of the packet** (the dump shows that field
is the IP version nibble, `4`). They are not socket states, which is precisely
why the chain never matches for IPv4 traffic — the value it tests is a header
word, not a state code. Naming them as "states" was a mistake now withdrawn.
### 44.4 What this leaves
The delivery function's early part is now understood: identify the packet, find
the PCB by (dport, dst IP, sport, src IP) against the binding table, and hand the
payload to the socket. The compare chain is a **header-value** test whose
constants are still unnamed, and it does not run for ordinary traffic — which is
consistent with it being a diagnostic/exceptional branch rather than the normal
path. That is a coherent end state for this corner, and it is supported by a
memory dump rather than by inference.
## 45. What the 5/8/13…18 compares actually test — a debug-gated header check
### 45.1 The index arithmetic, exactly
```
3F61EC80 setf 4 ; 4-bit field size
3F61EC80 a7 = *a11 ; nibble of the packet's first byte
3F61EC90 a9 = a7
3F61ECA0 a9 <<= 2 ; field * 2
...
3F61EF80 a7 = a9
3F61EF90 a7 <<= 3 ; field * 16 bits = field * 2 ... i.e. field*4 bytes after the <<2
3F61EFA0 a7 += a11 ; slot = packet + field*4 bytes
3F61EFB0 a7 = *a7,0 ; 16-bit word there
3F61F010 subk 8,a7
3F61F070 subk 13,a7
3F61F0D0 subk 14,a7 ... through subk 18
```
So the compared value is a **16-bit word at `packet + field*4 bytes`**, where the
field is a nibble of the packet's first byte.
### 45.2 What that lands on, using the captured dump
The section 44 dump of the same object (`a11` at `0x3F61EE70`) begins:
```
45 00 11 00 3f 99 00 40 40 11 00 00 c0 a8 63 01 c0 a8 63 02 ...
^ byte 0 = 0x45 (IPv4, IHL 5)
```
with `field` = `4` or `5` (the two nibbles of `0x45`) the slot lands at
`packet + 16` or `packet + 20` — i.e. on the **destination address** or the
**UDP source port** — not on any state word. Comparing those against 5, 8, 13…18
can essentially never be true for real traffic.
### 45.3 Why that is consistent with everything measured
* the chain has **0 executions** in `rttcp2.tr` across 44 segments and 12
datagrams (section 39.1, re-measured after this correction);
* the same function is guarded by a debug flag: at `0x3F61ECB0` it reads
`@0x1E0BF0` and, when set, logs through `0x3F623B80` before doing anything else
(the same gate appears in `0x3F622F10` and `0x3F623740`).
~~Taken together, the 5/8/13…18 compares are best described as a debug-gated
validation branch over packet header data.~~ **Superseded by section 46**: the
debug flag only gates a log call; the chain is gated by `dsj` on the IP protocol
byte and the constants are ICMP query types. That is a different thing from the "socket state enumeration"
this report claimed in sections 31.2 and 39, and both are now superseded:
| earlier claim | status |
|---|---|
| 31.2 "suppresses a set of socket states" | withdrawn |
| 39 "exceptional socket states, unnamed" | withdrawn |
| 43 "the per-entry word is a table slot on a PCB" | withdrawn (43.2: object is the packet) |
| 45 (this section) | debug-gated header-value test, never taken by normal traffic |
### 45.4 Where the socket layer now stands
Everything on the operational path is accounted for with evidence:
```
packet -> 0x3F61EC00: identify the datagram (packet buffer, section 44)
-> 0x3F61E490: match PCB by (dport, dst IP, sport, src IP) vs
(local port +0xB0, local addr +0x90, foreign port +0x80,
foreign addr +0x60) [section 31.1, 44.2]
-> deliver to the socket, or log an unmatched datagram (0x96500)
-> reply path 0x3F622A40 -> 0x3F623350 -> 0x3F62EA00 -> device
```
The unnamed constants were the last open item in this layer, and they are now
explained structurally rather than by name — which is the honest limit of a
ROM-only analysis of a debug branch that the running firmware never takes.
## 46. The 5/8/13…18 constants are ICMP types — the chain is protocol-gated, not debug-gated
Section 45 said the compare chain was "debug-gated". That was wrong: the debug
flag (`0x1E0BF0`) only gates a **log call**, and flipping it in RAM (verified this
turn: the flag was poked every 30 frames and the chain still did not run) changes
nothing. The real gate is one instruction earlier.
### 46.1 The guard: `dsj` on the IP protocol byte
```
3F61EF00 a7 = *(a11+0x48) ; byte 9 of the IP header = PROTOCOL
3F61EF20 dsj a7, L3F61F300 ; decrement; jump if the result is nonzero
```
`DSJ` is decrement-and-jump-if-nonzero (`34010ops.hxx`):
```c
#define DSJ(R) { if (--R##REG(DSTREG(op))) { m_pc += ...; } else { SKIP_WORD(); } }
```
so the fall-through — the path that computes the indexed word and runs the
compare chain — is taken **only when the protocol byte is 1**, i.e. **ICMP**.
That single fact explains every count observed so far:
| trace | protocol | `0x3F61EC00` | `0x3F61EF00` | chain (`0x3F61F010`…) |
|---|---|---|---|---|
| `rttcp2.tr` (UDP probes) | 17 | 11 | 11 | **0** — jumps away to `0x3F61F300` |
| `icmpw.tr` (echo requests) | 1 | 0 | 0 | **0** — echo requests are handled by `0x3F61FFA0` and never reach this function |
### 46.2 The constants are the ICMP query types
With the chain reached only for ICMP, the eight compared values are exactly the
ICMP types the stack answers itself:
| value | ICMP type |
|---|---|
| 5 | redirect |
| 8 | echo request |
| 13 | timestamp request |
| 14 | timestamp reply |
| 15 | information request |
| 16 | information reply |
| 17 | address mask request |
| 18 | address mask reply |
and the chain's action is to skip socket delivery (`jreq L3F61F300`) for those
types — because the stack answers them internally (section 22 shows the echo
request being rewritten and sent from the ICMP input path).
The indexed word is therefore **the ICMP type byte of the packet**, reached via
the packet's own first nibble — which is why the values are single-digit types and
why nothing else ever matches.
### 46.3 Corrections
| claim | status |
|---|---|
| 31.2 / 39 "socket states 5/8/13…18" | withdrawn (already) |
| 45 "debug-gated header test" | **wrong gate** — the debug flag only gates logging; the gate is `dsj` on the protocol byte |
| 45.4 "constants remain unnamed" | **now named**: ICMP query types |
### 46.4 Why this closes the item
The chain is not a socket-state suppression set and not a debug path: it is the
**ICMP "answer it ourselves" test**, one `dsj` away from being skipped for every
non-ICMP packet. Every measurement now agrees with that reading — the UDP path
skips it 11 times out of 11, and the ICMP path never reaches this function at all
because echo requests are answered earlier.
This is the first item in the report resolved by combining three independent
lines of evidence: the CPU core's `DSJ` semantics, the packet's protocol byte
(confirmed by the section 44 memory dump), and the exact constant set.
## 47. Double INIT changes nothing — and INIT is set in MAME too
### 47.1 The variant
`lance_init2.coff` writes `INIT` **twice** (the second before the `IDON` poll),
on the theory that a second write while `m_idon` is still false would leave
`INIT` visible:
| payload | csr0a | csr0b | ok | rmd1 | mcnt | txmd1 |
|---|---|---|---|---|---|---|
| `lance.coff` | `0x7B3` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
| `lance_init2.coff` | `0x7B3` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
Identical again. That is the **fourth** payload variant with byte-identical
results, and it rules out the "second INIT leaves INIT set" theory in the model.
### 47.2 The bit-by-bit difference between model and machine
Comparing the model's `CSR0 = 0x7B3` with the real chip's `0x1BB`:
| bit | name | MAME | real | |
|---|---|---|---|---|
| `0x001` | INIT | 1 | 1 | |
| `0x002` | STRT | 1 | 1 | |
| `0x004` | STOP | 0 | 0 | |
| `0x008` | TDMD | **0** | **1** | ← the demand was serviced only in the model |
| `0x010` | TXON | 1 | 1 | |
| `0x020` | RXON | 1 | 1 | |
| `0x040` | INEA | 0 | 0 | |
| `0x080` | INTR | 1 | 1 | |
| `0x100` | IDON | 1 | 1 | |
| `0x200` | TINT | **1** | **0** | ← no transmit completion on the machine |
| `0x400` | RINT | **1** | **0** | ← no receive on the machine |
Three facts come out of one table:
1. **`INIT` is set in the model too.** Sections 41.3 and 42.3 treated "INIT still
set" as a real-chip anomaly and built hypotheses on it. It is not an anomaly —
every payload run in MAME ends with `INIT` set. That hypothesis is withdrawn.
2. `STRT`, `TXON`, `RXON`, `IDON`, `INTR` agree exactly between model and machine:
the chip *was* started and *did* complete initialization.
3. The real differences are the **activity** bits: on the machine `TINT` and
`RINT` never fired and `TDMD` was never cleared, i.e. the chip accepted the
command set and then **did nothing at all** — no transmit poll, no receive.
### 47.3 What that means
The model clears `TDMD` at the top of `transmit_poll()` before it even looks at
the descriptor, so in MAME `TDMD = 0` is guaranteed once the poll is scheduled.
On the machine `TDMD` stayed set, so the poll was never scheduled. Since `TXON`
is set and `STRT` took effect, the remaining explanations are all about the
chip's internal scheduling rather than about the register write:
* the chip was still inside its initialization sequence (both `INIT` and `IDON`
set — the model tolerates that state, the machine may not);
* the transmit poll period is derived from the chip clock and our modelled clock
choice is wrong (the driver builds the device with a clock that the real board
may not match), so the poll never comes due in the payload's timing window;
* the payload's 50 000-iteration busy-wait is far shorter on real silicon than
the poll period.
The last one is testable **on the machine with no new tooling**: increase the
busy-wait after the `TDMD` write by an order of magnitude and see whether `TDMD`
clears. That is now the cheapest decisive experiment, ahead of any further
register archaeology.
### 47.4 Status
Four payload variants, all byte-identical in MAME; the model's `CSR0` compared
bit-for-bit with the machine's; the "INIT anomaly" withdrawn; and a concrete,
timing-based hypothesis added that the existing payload can test by waiting
longer. Real-silicon confirmation still needs the VT1200.
## 48. Long wait: the model never leaves TDMD set (0 of 600 polls)
### 48.1 The variant
`lance_longwait.coff` keeps the normal init and kick, then waits **600,000
iterations**, sampling `CSR0` every 1,000 iterations (600 polls) and recording:
* `cell(0x1C0)` — `CSR0` after the wait
* `cell(0x1E0)` — how many of the 600 polls still saw `TDMD` set
### 48.2 Result
```
lance_longwait: csr0a=0x7B3 csr0b=0x7B3 ok=1 rmd1=0x0300 mcnt=0x1C txmd1=0x8300
csr0long=0x7B3 tdmdsamples=0x00000000
lance (baseline): csr0a=0x7B3 csr0b=0x7B3 ok=1 rmd1=0x0300 mcnt=0x1C txmd1=0x8300
```
**0 of 600 polls saw `TDMD` set**, and `CSR0` after the wait is identical to the
baseline. In the model the demand is serviced before the first sample — the
poll is scheduled at zero delay (`m_transmit_poll->adjust(attime::zero, ...)`)
and `transmit_poll()` clears `TDMD` at its top.
### 48.3 What this closes
Together with sections 41, 42 and 47, the offline phase is now complete on this
question, with five variants and one timing sweep:
| payload | kick | init order | wait | TDMD left set? |
|---|---|---|---|---|
| `lance.coff` | `STRT\|TDMD` | STOP…INIT | 50 k | no |
| `lance_bare.coff` | `TDMD` | STOP…INIT | 50 k | no |
| `lance_stopinit.coff` | `STRT\|TDMD` | STOP,INIT adjacent | 50 k | no |
| `lance_init2.coff` | `STRT\|TDMD` | INIT twice | 50 k | no |
| `lance_longwait.coff` | `STRT\|TDMD` | STOP…INIT | 600 k, 600 polls | **no (0/600)** |
All five return `0x7B3 / ok=1 / 0x0300 / 0x1C / 0x8300`. **No offline
configuration reproduces the machine's `TDMD`-stuck state**, so the difference is
a property of the real chip that the model does not implement — not something
further payload variants can reveal.
### 48.4 The payload that answers it on the machine
`lance_longwait.coff` is also the right instrument for the hardware run: it
already records exactly the two numbers needed, in the marker cells the loader
prints.
```
expected if the chip polls: csr0long = (TDMD clear), tdmdsamples small
expected if the chip never polls: csr0long = TDMD set, tdmdsamples = 600
```
The intermediate case (`tdmdsamples` between 1 and 599, falling over time) would
mean the poll *is* running but slower than the payload's first samples — which
would immediately confirm the timing hypothesis of section 47.3. That is the
single most informative read available on the machine, and it needs no tooling
beyond running an existing build.
## 49. Cross-check against Linux's LANCE driver (`drivers/net/ethernet/amd/7990.c`)
An independent reference implementation was compared field by field with
`harness/udpstream/lance.c`. Linux's generic core is the right comparison: it is
the same chip family (Am7990/C-LANCE) driven on seven non-x86 buses.
### 49.1 The sequences side by side
Linux `lance_reset()` → `load_csrs()` → `lance_init_ring()` →
`init_restart_lance()`:
```c
WRITERDP(lp, LE_C0_STOP); /* stop */
WRITERAP(lp, LE_CSR1); WRITERDP(lp, leptr & 0xFFFF);
WRITERAP(lp, LE_CSR2); WRITERDP(lp, leptr >> 16);
WRITERAP(lp, LE_CSR3); WRITERDP(lp, lp->busmaster_regval);
WRITERDP(lp, LE_C0_INIT); /* initialize */
for (i = 0; (i < 100) && !(READRDP(lp) & (LE_C0_ERR | LE_C0_IDON)); i++);
if ((i == 100) || (READRDP(lp) & LE_C0_ERR)) return -1;
WRITERDP(lp, LE_C0_IDON); /* <<< CLEAR IDON */
WRITERDP(lp, LE_C0_INEA | LE_C0_STRT); /* start, interrupts on */
```
| step | Linux `7990.c` | `lance.c` (ours) | note |
|---|---|---|---|
| STOP first | yes | yes (line 131) | same |
| CSR1 / CSR2 | init-block address | same | same |
| CSR3 | `busmaster_regval` (0 for little-endian, `LE_C3_BSWP` for big-endian; declance comments "should be zero") | `0` | matches the firmware's own value |
| INIT | yes | yes | same |
| poll | `ERR \| IDON`, 100 tries | `IDON` only, 20 000 tries | **ours ignores ERR** |
| **clear IDON** | **`WRITERDP(lp, LE_C0_IDON)`** | **not done** | **only real difference in ordering** |
| start | `INEA \| STRT` | `STRT` alone | interrupts irrelevant (we poll) |
| kick | `INEA \| TDMD` | `STRT \| TDMD` | equivalent command bits |
| init-block mode | `LE_MO_PROM` (0x8000) | `LE_MO_LOOP\|LE_MO_INTL` (0x0044) | loopback is the point of the test |
| TX descriptor length | `0xf000` then set at xmit | `0x0FE8` (24 B) | same negative-length convention |
| RX descriptor length | `-RX_BUFF_SIZE \| 0xf000` | `0x0A0A` (-1526) | same convention |
| OWN bit | set at transmit time | set at kick | same effect |
### 49.2 The one substantive difference (weakened — see section 50.5)
Linux **writes `IDON` back (clearing it) before writing `STRT`**. Our payload
leaves `IDON` set, and the machine's register snapshot was
`CSR0 = 0x1BB`, which contains **both `IDON` and `INIT` set** while `STRT`,
`TXON` and `TDMD` are also set.
That combination is exactly what a chip looks like when it has latched the start
command but never left the "initialization done, not acknowledged" state:
* `STRT`/`TXON` readable as set — the bit was accepted;
* `IDON` still pending because nobody acknowledged it;
* consequently the internal sequencer never enters normal operation, so the
transmit poll is never scheduled and `TDMD` is never cleared.
The model cannot show this because `transmit_poll()` is scheduled on the `STRT`
write unconditionally and clears `TDMD` on its first tick.
This is the first hypothesis derived from an independent implementation rather
than from the ROM alone, and it is cheap to test.
### 49.3 Variant built to test it
`lance_idonclear.c` / `lance_idonclear.coff` inserts, right after the `IDON`
poll and before the start:
```c
csr_w(0, CSR0_IDON); /* clear IDON by writing 1, as Linux does */
for (idle = 0; idle < 2000; idle++) { }
```
Everything else is unchanged. In MAME this should be a no-op (the model ignores
the acknowledgement), and on the machine it is the sequence Linux has used on
seven architectures.
### 49.4 MAME result for the IDON-clearing variant
| payload | csr0 | ok | rmd1 | mcnt | txmd1 |
|---|---|---|---|---|---|
| `lance.coff` | `0x7B3` | 1 | `0x0300` | `0x1C` | `0x8300` |
| `lance_idonclear.coff` | **`0x6B3`** | 1 | `0x0300` | `0x1C` | `0x8300` |
`0x7B3 − 0x6B3 = 0x100` = `IDON`. So the acknowledgement takes effect exactly as
the datasheet describes, nothing else changes, and there is no regression — the
loopback still completes (`ok=1`, `rmd1=0x0300`, `mcnt=0x1C`).
In MAME the two payloads differ only in that bit, because the model's
`transmit_poll()` is scheduled on `STRT` regardless. On the machine this variant
is the first that follows the sequence every Linux LANCE driver uses:
```
STOP -> CSR1/2/3 -> INIT -> wait IDON -> CLEAR IDON -> INEA|STRT -> TDMD
```
If `TDMD` clears with `lance_idonclear.coff` but not with `lance.coff`, the
missing `IDON` acknowledgement is the answer to the whole hardware question. If
it still does not clear, `TDMD`-stuck is independent of the init handshake and
the remaining candidates are the poll period / clock model (section 47.3).
## 50. The descriptor length encoding was wrong — found by comparing drivers
The NetBSD drivers, the Linux driver and the VT1200 firmware all agree on one
detail that our payload got wrong, and it explains the machine's symptoms better
than any earlier hypothesis.
### 50.1 The rule: the length's top nibble must be ones
NetBSD `sys/dev/ic/am7990reg.h`:
```c
#define LE_XMD2_ONES 0xf000
```
and `sys/dev/ic/am7990.c` writes every descriptor length as
```c
rmd.rmd2 = -LEBLEN | LE_XMD2_ONES; /* receive */
tmd.tmd2 = 0 | LE_XMD2_ONES; /* transmit, idle */
tmd.tmd2 = -len | LE_XMD2_ONES; /* transmit, real length */
...
len = -tmd.tmd2; /* read back */
```
Linux `7990.c` says the same in comments:
```c
ib->btx_ring[i].length = 0xf000; /* The ones required by tmd2 */
ib->brx_ring[i].length = -RX_BUFF_SIZE | 0xf000; /* 0xf000 == bits that must be one */
```
### 50.2 The VT1200 firmware obeys it too
From this report's own disassembly:
* RX arming `0x3F9D36C0` writes the length with `setf 12` and the value
**`0xFDDA`** = `0xF000 | 0x0DDA` -> 550 bytes, top nibble **ones** (section 10);
* the TX ring initialiser `0x3F9D2FF0` writes **`0xF000`** as the idle TX length
(section 10).
So the firmware writes exactly the encoding NetBSD and Linux use.
### 50.3 What our payload wrote, and why MAME hid it
```
lance.c (before): RX length 0x0A0A TX length 0x0FE8
required: RX length 0xFA0A TX length 0xFFE8 (0xF000 | -len)
```
MAME's model **ORs the nibble in itself**:
```cpp
virtual int get_buf_length(u16 data) const override
{ return (data == 0xf000) ? 4096 : -s16(0xf000 | data); }
```
so `0x0A0A` is silently turned into `0xFA0A` and our payload passes every MAME
test. Read literally, our values are **positive** (4072 and 2570) where the chip
expects a **negative** length — invalid for both directions.
### 50.4 Why this explains the whole machine result
A chip rejecting both descriptor sets produces exactly what was measured:
| observation | explanation |
|---|---|
| `TDMD` still set, no `TINT` | the transmit descriptor is invalid, the transmit never completes |
| no `RINT`, no receive | the receive descriptors are invalid, nothing is received |
| `STRT`/`TXON`/`IDON` set, `ERR` clear | the chip is initialised and started, it just has nothing valid to do |
That is a single cause for both missing interrupt bits, and it does not depend on
chip-internal behaviour the model does not implement.
### 50.5 Correction to section 49.2
The `IDON`-clearing hypothesis is weakened: **NetBSD does not clear `IDON`**
either (`sys/dev/ic/lance.c` writes `LE_C0_INEA | LE_C0_STRT` immediately once
`IDON` is set). Two mainstream drivers disagree on that step and both work, so it
is not a requirement. The difference that matters is the length encoding, which
all three implementations agree on.
### 50.6 Fixed payload
`lance_fixlen.c` / `lance_fixlen.coff` writes `0xFA0A` and `0xFFE8`. In MAME it is
identical to the baseline (`0x7B3 / ok=1 / 0x0300 / 0x1C / 0x8300`), as it must
be, because the model masks the nibble. On the machine it is the first payload
whose descriptors match what the firmware, Linux and NetBSD all write.
## 51. Provenance: the stack descends from 386BSD — and it corroborates section 46
The local NetBSD tree (`/home/arti/code/NetBSD-src`) carries history back to
**1992-07-13**, including a `4.4BSD-Lite2` commit and, on 1993-03-21, the
**initial import of 386BSD-0.1 sources**. That is the right vintage for this
terminal, and it turns out to be usable as a reference implementation.
### 51.1 An exact string match
The ROM contains `arp: ether address is broadcast for IP address …`
(section 26.1). In the 386BSD import:
```
a319363d6037:sys/netinet/if_ether.c:350:
"arp: ether address is broadcast for IP address %x!\n",
```
Modern NetBSD (`sys/netinet/if_arp.c:929`) says *"link address"* instead, so the
VT1200 matches the **older** wording — the same 386BSD/4.3–4.4 lineage. Other
messages in the same ROM (`arpresolve: no free entry`) belong to that file too.
### 51.2 The ICMP case set is the 386BSD one
Section 46 concluded from the `dsj` + compare chain that the constants
5, 8, 13, 14, 15, 16, 17, 18 are ICMP types. The 386BSD `ip_icmp.c` switch
(`a319363d6037:sys/netinet/ip_icmp.c`) contains exactly these case labels:
```
case ICMP_REDIRECT: /* 5 */
case ICMP_ECHO: /* 8 */
case ICMP_TSTAMP: /* 13 */
case ICMP_TSTAMPREPLY: /* 14 */
case ICMP_IREQ: /* 15 */
case ICMP_IREQREPLY: /* 16 */
case ICMP_MASKREQ: /* 17 */
case ICMP_MASKREPLY: /* 18 */
```
(plus the error types `UNREACH`(3), `SOURCEQUENCH`(4), `TIMXCEED`(11),
`PARAMPROB`(12) and `ECHOREPLY`(0), which our chain does not test). That is
independent confirmation of the section 46 reading from the reference source,
not from the ROM alone.
### 51.3 LANCE driver history in the same tree
| path | first added |
|---|---|
| `sys/arch/i386/isa/if_le.c` | 1994-07-01 |
| `sys/dev/isa/if_le.c` | 1994-07-01 |
| `sys/dev/ic/am7990.c` | 1995-06-28 |
| `sys/dev/ic/lance.c` | 1998-07-21 |
The 1994 ISA driver is the closest in date to the firmware, and `am7990.c` (1995)
is the source of the descriptor-length rule that section 50 identifies as our
payload's bug.
Note on method: this tree is ~7 GB, so repo-wide `git log -S`/`--all` scans take
minutes. Path-limited queries are immediate and were used instead:
```sh
git log --diff-filter=A --format='%ad %h' --date=short -- sys/dev/isa/if_le.c
git show <commit>:sys/netinet/ip_icmp.c
```
## 52. CSRG tree (`original-bsd`) — the firmware's own lineage, and IDON settled
`/home/arti/code/original-bsd` is the CSRG source tree: history back to
**1979-12-04** (`3bsd version`), last commit the final CSRG delta (4.4BSD-Lite2).
180 MB, so full-tree searches are instant — the right tree for provenance
questions.
### 52.1 Same ARP code, one release older
```
sys/netinet/if_ether.c:409:
"arp: ether address is broadcast for IP address %x!\n",
```
identical to the string in the ROM and to the 386BSD import, and unlike modern
NetBSD's "link address" wording.
### 52.2 LANCE drivers present in 4.4BSD
```
sys/hp300/dev/if_le.c sys/luna68k/dev/if_le.c
sys/pmax/dev/if_le.c sys/sparc/sbus/if_le.c
sys/news3400/if/if_lance.c
```
The **pmax (DECstation)** driver is the closest sibling to a DEC-built terminal
stack, and its init sequence is (`sys/pmax/dev/if_le.c:405-419`):
```c
LEWREG(LE_CSR1, ...); LEWREG(init_block_addr_low, ...);
LEWREG(LE_CSR2, ...); LEWREG(0, ...);
LEWREG(LE_CSR3, ...); LEWREG(0, ...);
LEWREG(LE_CSR0, ...); LERDWR(ler0, LE_INIT, ...);
do { ... } while ((stat & LE_IDON) == 0);
LERDWR(ler0, LE_IDON, ...); /* clear IDON by writing 1 */
LERDWR(ler0, LE_STRT | LE_INEA, ...);
```
So 4.4BSD and Linux both clear `IDON`; NetBSD's later driver does not.
### 52.3 What the VT1200 firmware itself does — and why that settles it
The firmware's own init (`io5800_5900_init`, `0x3FDDCBE0`) writes only:
```
RAP=1 -> init block low RAP=2 -> init block high
RAP=3 -> 0 RAP=0 -> 0x7F41
```
`0x7F41` = `INIT | INEA | (all status bits written as 1 to clear them)`. The
poll and start live in its caller, and the live path captured in `LANCELOG`
(section 3) is:
```
CSR0 = 0x0001 (INIT) -> poll IDON (0x0181) -> CSR0 = 0x0042 (STRT|INEA)
```
**No `IDON` clear anywhere** — and this firmware demonstrably runs the terminal's
network stack on real hardware.
Therefore clearing `IDON` is *optional*, confirmed from three directions:
a shipping driver that omits it (NetBSD), two that include it (4.4BSD pmax,
Linux), and the firmware itself, which omits it and works.
### 52.4 Result for the hardware question
Section 49.2's `IDON` hypothesis is now **dead**, and section 50 is left as the
only explanation that fits all the evidence: the descriptor length fields must be
`0xF000 | -len`, our payload's were not, MAME masks the difference in
`get_buf_length()`, and a chip rejecting both descriptor sets produces exactly
the measured state (`TDMD` never cleared, no `TINT`, no `RINT`, `ERR` clear).
`lance_fixlen.coff` remains the payload to run on the machine.
## 53. Mapping the report's functions to 4.4BSD source
Every function reversed above has a counterpart in
`/home/arti/code/original-bsd` (CSRG, 4.4BSD-Lite2). Mapping ROM address →
source function, with the construct that identifies each pair.
### 53.1 Protocol stack
| ROM | BSD source | evidence |
|---|---|---|
| `0x3F624980` IP task | `ipintr()` `sys/netinet/ip_input.c:115` | takes the packet from a queue, then dispatches (below) |
| `0x3F6270F0` protocol dispatch | `ip_input.c:366` `(*inetsw[ip_protox[ip->ip_p]].pr_input)(m, hlen);` | **both tables found in RAM**: `0x1E0F70` = `ip_protox[256]` (protocol→index), `0xD6BE0` = `inetsw[]` |
| — struct layout | `struct protosw` `sys/sys/protosw.h:32` | `pr_protocol` at byte 6 and `pr_input` at byte 10 — exactly the offsets in our `0xD6BE0` dump (section 23.2) |
| `0x3F61FFA0` ICMP input | `icmp_input()` `sys/netinet/ip_icmp.c:148` | checksum verify over the ICMP header, then the type switch |
| `0x3F6224E0` type dispatch | the `switch (icm->icmp_type)` in `icmp_input()` | same case set (section 46, 51.2) |
| `0x3F622A40` reflect | `icmp_reflect()` `ip_icmp.c:386` | swaps source/destination, adjusts the total length, recomputes the checksum |
| `0x3F623350` ICMP send | `icmp_send()` `ip_icmp.c:510` | final transmit of the reply |
| `0x3F62EA00` IP transmit | `ip_output()` `sys/netinet/ip_output.c:43` | htons of total length and fragment word, header checksum at byte 10, then the interface's output |
| `0x3F63A340` TCP input | `tcp_input()` `sys/netinet/tcp_input.c:189` | states 3..10 tables (section 24) are `tcp_input`'s `switch (tp->t_state)` |
| TCP options | `tcp_dooptions()` `tcp_input.c:1309` | called from `tcp_input` at `:387` and `:567` |
| MSS handling | `tcp_mss()` `tcp_input.c:1501` | called from `tcp_dooptions` at `:1343` (`(void) tcp_mss(tp, mss); /* sets t_maxseg */`) |
| `0x3F6540C0` UDP input | `udp_input()` `sys/netinet/udp_usrreq.c:55` | header length checks, then the PCB lookup below |
| `0x3F656200` UDP header builder | `udp_output()` `udp_usrreq.c:351` | writes protocol 17 into the header (section 20) |
| `0x3F61E490` binding lookup | **`in_pcblookup()` `sys/netinet/in_pcb.c:428`** | exact match of the algorithm — see 53.2 |
| `0x3F6148E0`, `0x3F614200` ARP cache | `arplookup()` `if_ether.c:500`, `arpresolve()` `:261` | 19-bucket hash by IP, 9 entries (section 26) |
| `0x3F6119A0` ARP responder | **`in_arpinput()` `sys/netinet/if_ether.c:375`** | the "ether address is broadcast" diagnostic lives in it (section 51.1) |
| `0x3F60F260` ARP request | `arprequest()` `if_ether.c:216`, `arpwhohas()` `:202` | builds a 28-byte ARP payload |
### 53.2 `in_pcblookup` — line-for-line
```
in_pcb.c:434 int matchwild = 3, wildcard; <-- our `movk 3,a11`
in_pcb.c:437 if (inp->inp_lport != lport) continue; <-- entry+0xB0 vs arg1
in_pcb.c:439 if (inp->inp_laddr.s_addr != INADDR_ANY) <-- entry+0x90 vs arg2
in_pcb.c:452 if (inp->inp_faddr.s_addr != INADDR_ANY ||
inp->inp_fport != fport) continue; <-- entry+0x60 / +0x80
in_pcb.c:462 if (wildcard < matchwild) { match = inp; matchwild = wildcard; ... }
```
which is exactly the loop at `0x3F61E570`..`0x3F61EA20`, including the
`wildcard=3` start and the "best match wins" selection. The PCB field offsets in
section 31.1 are therefore `inp_lport`(22), `inp_laddr`(18), `inp_faddr`(12),
`inp_fport`(16).
### 53.3 LANCE driver
| ROM | BSD source (`sys/pmax/dev/if_le.c`, DECstation) | evidence |
|---|---|---|
| `0x3F9D4BC0` driver command entry (init + send) | `leinit()` `:429`, `lereset()` `:359` | CSR1/CSR2 = init block, CSR3 = 0, CSR0 = INIT, poll IDON, STRT |
| `0x3F9D36C0` ring arming | `ledrinit()` `:337`, `leinit()` | descriptor address/length/OWN setup |
| `0x3F9D57F0` TX kick | **`lestart()` `:458`** | `LER2_tmd1(tmd, LE_OWN|LE_STP|LE_ENP);` then `LERDWR(ler0, LE_TDMD|LE_INEA, ...)` |
| `0x3F9D5B40` interrupt dispatch | **`leintr()` `:501`** | reads CSR0, tests `LE_INTR`, `LE_SERR`, RXON/TXON, then RINT/TINT |
| `0x3F9D62B0` RX poll | **`lerint()` `:606`** | walks the receive ring, hands completed buffers up |
| `0x3F9D8AF0` TX completion | the TINT half of `leintr()` | releases the transmitted descriptors |
| `0x3F9DCF50`→`0x3F9D4BC0` submission | `if_start` → `lestart()` | the queue-to-ring hand-off |
### 53.4 What the mapping settles about the descriptor length
`lestart()` writes the transmit length as **plain two's complement**:
```c
LER2_tmd2(tmd, -len); /* if_le.c:485 */
```
For a 24-byte frame that is `0xFFE8`. For the receive side the same arithmetic
gives `0xFA0A` for 1526 bytes. Our payload wrote `0x0FE8` and `0x0A0A` — the
**positive** values, missing the top nibble that two's complement produces.
This confirms section 50 from a third independent direction and simplifies the
rule: the field is simply `-len`, and for any length under 4096 that always has
the top nibble set. There is no separate "0xF000 convention" to remember — a
positive value is simply wrong.
## 54. Remaining mappings: raw-IP delivery, TCP state switches, ICMP checksums
### 54.1 `0x3F61EC00` is `rip_input()` — raw IP delivery
`sys/netinet/raw_ip.c:58`. The three identifying constructs:
```c
raw_ip.c:65 for (inp = rawinpcb.inp_next; inp != &rawinpcb; inp = inp->inp_next) {
raw_ip.c:66 if (inp->inp_ip.ip_p && inp->inp_ip.ip_p != ip->ip_p) continue;
raw_ip.c:68 if (inp->inp_laddr.s_addr && inp->inp_laddr.s_addr != ip->ip_dst.s_addr) continue;
raw_ip.c:70 if (inp->inp_faddr.s_addr && inp->inp_faddr.s_addr != ip->ip_src.s_addr) continue;
raw_ip.c:80 sbappendaddr(&last->so_rcv, (struct sockaddr *)&ripsrc, n, 0)
raw_ip.c:96 sbappendaddr(&last->so_rcv, ...)
raw_ip.c:100 ipstat.ips_noproto++;
```
which are exactly the parts of `0x3F61EC00` measured earlier: a PCB-list walk
comparing a protocol word, a local and a foreign address, delivery through a
queue append, and the **`ips_noproto` counter on no match** — the counter we
recorded at `0x96500` (section 29.2).
The `dsj` gate found in section 46 compares that PCB field against 1, i.e. the
raw socket bound to **ICMP** (`inp->inp_ip.ip_p == IPPROTO_ICMP`); the
5/8/13…18 skip list is then the raw-ICMP socket filter — those are the types the
kernel answers internally, so a raw socket must not also receive them.
### 54.2 The TCP state tables are `tcp_input`'s two switches
`sys/netinet/tcp_input.c` has two dispatches on `tp->t_state`:
```
tcp_input.c:506 switch (tp->t_state) { <-- our table B (0x3F6417C0)
tcp_input.c:823 if (tiflags&TH_RST) switch (tp->t_state) {
tcp_input.c:825 case TCPS_SYN_RECEIVED:
tcp_input.c:829 case TCPS_ESTABLISHED:
tcp_input.c:840 case TCPS_CLOSING:
tcp_input.c:841 case TCPS_LAST_ACK:
tcp_input.c:842 case TCPS_TIME_WAIT:
tcp_input.c:865 switch (tp->t_state) { <-- our table A (0x3F63F350)
tcp_input.c:872 case TCPS_SYN_RECEIVED:
tcp_input.c:897 case TCPS_ESTABLISHED:
```
The grouping matches table A exactly: in section 24.2, states **7 CLOSING,
8 LAST_ACK and 10 TIME_WAIT all share the target `0x3F63F200`** — the same
fallthrough group as `tcp_input.c:840-842`. Table A's SYN_RECEIVED handled
separately and ESTABLISHED family grouped also matches `:872` vs `:897`.
### 54.3 ICMP checksum sites
| ROM | source | evidence |
|---|---|---|
| `0x3F6206D0` (verify) | `ip_icmp.c:184` `if (in_cksum(m, icmplen))` | checksum over the ICMP message before the type switch |
| `0x3F623350` (reflect) | `ip_icmp.c:522-523` `icp->icmp_cksum = 0; icp->icmp_cksum = in_cksum(...)` | zero the field, recompute, store — exactly the sequence at `0x3F62FF60`..`0x3F630000` |
### 54.4 Coverage
With 53 and 54 the **protocol-layer** functions all have a BSD counterpart:
IP (`ipintr`, `inetsw[ip_protox[]]`, `ip_output`), ICMP (`icmp_input`,
`icmp_reflect`, `icmp_send`), TCP (`tcp_input`, `tcp_dooptions`, `tcp_mss`),
UDP (`udp_input`, `udp_output`), raw IP (`rip_input`), ARP (`in_arpinput`,
`arpresolve`, `arplookup`, `arprequest`) and the PCB lookup (`in_pcblookup`).
Driver functions map to `leinit`/`lereset`/`ledrinit`/`lestart`/`leintr`/`lerint`,
**but not every addressed function is driver code**: the deferred receive/submit
path and the interrupt dispatcher are the firmware's own glue and have no 4.4BSD
counterpart. Section 56 completes the list, quotes the identifiers that were
missing here, and replaces an earlier over-broad claim ("every function the
report gives an address for") with that accurate statement.
## 55. The PCB field order proves the mapping at struct level
Section 31.1 gave the binding entry's fields from disassembly alone. The BSD
struct confirms them exactly, and the agreement is only possible with one
alignment:
```c
struct inpcb { /* sys/netinet/in_pcb.h */
struct inpcb *inp_next, *inp_prev; /* 0, 4 */
struct inpcb *inp_head; /* 8 */
struct in_addr inp_faddr; /* 12 */
u_short inp_fport; /* 16 */
struct in_addr inp_laddr; /* 18 (16-bit alignment) */
u_short inp_lport; /* 22 */
struct socket *inp_socket; /* 24 */
...
```
| field | source offset | our ROM offset | match |
|---|---|---|---|
| `inp_faddr` | 12 | `+0x60` bits = 12 | ✓ |
| `inp_fport` | 16 | `+0x80` bits = 16 | ✓ |
| `inp_laddr` | 18 | `+0x90` bits = 18 | ✓ |
| `inp_lport` | 22 | `+0xB0` bits = 22 | ✓ |
All four in the same order as `in_pcblookup()` tests them. On a 32-bit-aligned
build `inp_laddr` would sit at 20 and `inp_lport` at 24, so the exact match also
establishes that the firmware's ABI uses **16-bit alignment** — consistent with
the `struct protosw` offsets (6/10) in section 53.1.
This closes the loop on the socket layer: the ROM's offsets, the source's field
order, and the lookup algorithm are all the same object.
## 56. Completing the mapping: quoted identifiers, and what has no BSD counterpart
Sections 53–55 mapped most addressed functions but three rows were asserted
without a quoted line, and seven addressed functions were not in any row. Both
gaps are closed here. **Section 54.4's claim that "every function the report
gives an address for now has a BSD counterpart" was not true and is corrected in
56.3.**
### 56.1 Quoted identifiers for the rows that lacked one
**`ip_output()` — `sys/netinet/ip_output.c:258-261`** matches the ROM's
`0x3F62EA00` sequence operation for operation:
```c
ip->ip_len = htons((u_short)ip->ip_len);
ip->ip_off = htons((u_short)ip->ip_off);
ip->ip_sum = 0;
ip->ip_sum = in_cksum(m, hlen);
```
(our block `0x3F62FEB0`..`0x3F630000` does exactly: htons of the word at byte 2,
htons of the word at byte 6, zero the word at byte 10, checksum, store).
**`udp_input()` — `sys/netinet/udp_usrreq.c`**: the preamble and the PCB lookup
from the datagram's own header fields:
```c
:96 len = ntohs((u_short)uh->uh_ulen);
:114 if (uh->uh_sum) {
:119 if (uh->uh_sum = in_cksum(m, len + sizeof (struct ip))) {
:223 inp = in_pcblookup(&udb, ip->ip_src, uh->uh_sport,
ip->ip_dst, uh->uh_dport, INPLOOKUP_WILDCARD);
```
which is why the lookup arguments in `0x3F654980..0x3F654AA0` are
(dport, dst, sport, src) read straight out of the packet (section 44.2).
**`udp_output()` — same file**, the checksum the builder leaves for the end:
```c
:415 if ((ui->ui_sum = in_cksum(m, sizeof (struct udpiphdr) + len)) == 0)
```
**`tcp_dooptions()` — `sys/netinet/tcp_input.c:1309`**, called from two places in
`tcp_input` (`:387` and `:567`), which is the options loop our TCP input runs
over the header.
### 56.2 The addressed functions with no BSD counterpart
These are the VT1200's own glue. The structural reason is visible in the source:
BSD's receive path hands the frame up **inside the interrupt**, and its transmit
path is a direct `if_start` call —
```c
if_le.c:761 ether_input(&le->sc_if, &et, m); /* inside lerint() */
if_le.c:230 ifp->if_start = lestart;
if_le.c:443 (void) lestart(ifp);
if_le.c:942 lestart(ifp);
```
— whereas the firmware **defers both** through queues and event posts.
| ROM | role | status |
|---|---|---|
| `0x3F602E00` | RX entry: buffer state, re-arm, post | **VT1200 glue** (deferred RX; BSD calls `ether_input` inline at `if_le.c:761`) |
| `0x3F9E0B80` | prepare the frame for the block | **VT1200 glue**, same deferral |
| `0x3F600200` | frame -> packet block (lengths `0x5EE`/`0x226`/`0x44C`) | analogous to `leget()` (`if_le.c:797`, the mbuf side), but operates on the VT1200 packet block — **no line-level counterpart** |
| `0x3F603250` | enqueue to `0x96AE0` + post "IP input ready" | **VT1200 glue** (BSD has no queue here) |
| `0x3F9DC890` | submission wrapper before the driver entry | **VT1200 glue** around `if_start`/`lestart` (`if_le.c:230/443/942`) |
| `0x3F9DCF50` | the call site of the driver entry `0x3F9D4BC0` | **VT1200 glue** (the wrapper's inner call) |
| `0x3F7144C0` | OS interrupt dispatcher (handler table `0x3F7151F0`) | **VT1200 OS**, no BSD counterpart; BSD's equivalent is the platform vector that calls `leintr()` (`if_le.c:76` declares it) |
### 56.3 Correction to 54.4
Section 54.4 said every addressed function has a BSD counterpart. The accurate
statement is:
> **Every protocol-layer function the report addresses maps to a 4.4BSD
> function with a quoted identifier. The driver's receive and submit paths are
> split differently in the firmware (queue + task, and a submission wrapper), so
> four of them are VT1200 glue with no source counterpart, and the interrupt
> dispatcher belongs to the firmware's own OS.**
That is the honest boundary of the mapping: the protocol stack is 4.4BSD
lineage, the driver's *deferred* structure is not.
## 57. The remaining addressed functions: codec table, dispatchers, re-arm
Sections 53–56 left four groups out. Each is now either given a counterpart or
recorded as firmware-specific with the reason.
### 57.1 `0x3F660380` + table `0x1E3D80` — firmware field-codec table
Structure (section 18): index `*(a13+0xC0)` in 0..16, entry = **8 bytes**, `fn0`
at `+0` and `fn1` at `+0x20` bits (= byte 4); entries 0,1 and 4..15 share one
pair (`0x3F658570`/`0x3F658650`), entries 2/3 point at `0x3F615E00` /
`0x3F616090`.
**VT1200-specific, no 4.4BSD counterpart.** Checked for the two things it could
be: there is no XDR/RPC directory in this tree (`lib/libc/rpc` does not exist)
and no kernel table of function-pointer pairs indexed by a type code — the only
dispatch tables 4.4BSD has are `inetsw[]` (already mapped, section 53.1) and
`struct domain`, neither of which is a per-field codec.
### 57.2 `0x3F615E00` and `0x3F616090` — the convert/compare pair
| ROM | does | nearest 4.4BSD idiom | counterpart? |
|---|---|---|---|
| `0x3F615E00` | swaps a 32-bit packet field to host order, stores it, calls `0x3FB2C140` | `ntohl()` / `ntohs()` byte-order macros (`sys/netinet/in.h`) | **idiom only** — in BSD this is a macro inlined at each use, not a dispatch entry |
| `0x3F616090` | converts two values and compares them, returns 1/0 | `in_pcblookup()`'s address tests, e.g. `if (inp->inp_laddr.s_addr != laddr.s_addr) continue;` (`in_pcb.c:441`) | **idiom only** |
So the *operations* are ordinary BSD operations, but the *table-driven
{convert, compare} per field type* is the firmware's own design. Neither has a
line-level counterpart.
### 57.3 `0x3F65A570` and its caller `0x3F6A86D0`
| ROM | what was established | status |
|---|---|---|
| `0x3F65A570` | four-case compare chain on a type word; reaches the ARP responder; **not reachable from any EtherType** (section 38); zero executions in every trace | **VT1200-specific dispatcher**, no BSD counterpart |
| `0x3F6A86D0` | its caller: extracts byte 1 of the argument and tests it against `0x72` (`cmpi 0x72`), i.e. a character-coded protocol id | **VT1200-specific**, no BSD counterpart |
### 57.4 The receive re-arm — `0x3F9D17E0`
Section 30.1 recorded that `0x3F602E00` re-arms the receive buffer, but omitted
the address. It is `0x3F9D17E0`, installed by the driver as the RX re-arm/post
routine (it appears as `movi 0x3f9d17e0` at `0x3F602EC0` and `0x3F603100`).
| ROM | 4.4BSD counterpart | evidence |
|---|---|---|
| `0x3F9D17E0` | `lereset()` `if_le.c:359` / `ledrinit()` `:337` — the driver's own re-initialisation of the ring | both rebuild the descriptor ring and hand buffers back to the chip; the firmware additionally posts it as a callback, which is the deferred structure noted in 56.2 |
(Note: an earlier review note referred to this as `0x3F9817E0`; the address in
the ROM is `3F9D17E0`.)
### 57.5 Coverage, final form
The mapping now covers, with a cited line or an explicit firmware-specific
entry, every function the report gives an address for:
| group | mapped to |
|---|---|
| protocol layer | 4.4BSD with quoted lines (53.1, 54, 56.1) |
| driver | `leinit`/`lereset`/`ledrinit`/`lestart`/`leintr`/`lerint` (53.3, 57.4) |
| deferred receive / submit / IRQ glue | firmware-specific (56.2) |
| codec table, protocol-id dispatcher | firmware-specific (57.1–57.3) |
Section 56.3's statement is therefore extended: *the protocol stack is 4.4BSD
lineage with function-for-function counterparts; the driver maps onto the pmax
`le*` routines; and the deferred I/O structure, the field-codec table and the
protocol-id dispatcher are the firmware's own, with no source counterpart.*
## 58. TCP helpers, socket/queue helpers and utilities — mapped
### 58.1 TCP helpers
| ROM | 4.4BSD counterpart | quoted evidence |
|---|---|---|
| `0x3F645170` "tcp output" | **`tcp_output()` `sys/netinet/tcp_output.c:46`** | `register struct socket *so = tp->t_inpcb->inp_socket;` then the length/window computation — the same field reads (`+0x280`, `+0x120`, `+0x140`, `+0x1E0`, `+0x2A0`) our function performs |
| `0x3F635910` "alloc/copy" | **`tcp_template()` `sys/netinet/tcp_subr.c:65`** | copies the segment header template into the connection block; the *allocator* (160-byte records from a pool, section 24.6) is the firmware's own |
| `0x3F64C730` close helper | **`tcp_close()` `sys/netinet/tcp_subr.c:244`** | the teardown called from the CLOSING/LAST_ACK/TIME_WAIT handler `0x3F63F200` (section 30.3) |
| `0x3F64E960` (timeout branch) | **`tcp_timers()` `sys/netinet/tcp_timer.c:144`, `case TCPT_2MSL:` `:158`** | the timer expiry path |
| `0x3F6BE2F0` (schedule it) | **`timeout()` `sys/kern/kern_clock.c:232`** | generic kernel timer scheduling |
### 58.2 The 120 in TIME_WAIT is `2 * TCPTV_MSL`
Section 32.2 confirmed on live traffic that entering TIME_WAIT stores **120** at
TCB `+0x80`. In the source that constant is:
```c
tcp_timer.h:60 #define TCPTV_MSL ( 30*PR_SLOWHZ) /* max seg lifetime */
tcp_input.c:1068 tp->t_timer[TCPT_2MSL] = 2 * TCPTV_MSL;
```
with `PR_SLOWHZ == 2` that is `2 * 60 = 120` — exactly the value measured. So
TCB `+0x80` is `t_timer[TCPT_2MSL]`, not a generic timeout field.
### 58.3 Socket/interface helper and the queue post
| ROM | counterpart | evidence |
|---|---|---|
| `0x3F61AB30` (walks the interface list, compares an address) | **`ifa_ifwithaddr()` `sys/net/if.c:141`** | `for (ifp = ifnet; ifp; ifp = ifp->if_next) for (ifa = ifp->if_addrlist; ...) if (equal(addr, ifa->ifa_addr))` — our function walks `0x96AC0` and compares through `0x3FB2C140` |
| `0x3F61B000` | **firmware-specific** | a lock wrapper around `0x3F6AC880`; BSD has no equivalent call shape here |
| `0x3F61BFC0` | **firmware-specific** | packet-field access after the header-pointer computation, no BSD counterpart |
| `0x3F60BD40` (queue post after enqueue) | **firmware-specific** | BSD calls `ether_input`/`ipintr` directly (section 56.2) |
### 58.4 Utilities
| ROM | counterpart | quoted evidence |
|---|---|---|
| `0x3FB2C290` (ones-complement sum) | **`in_cksum()` `sys/netinet/in_cksum.c:24`** | `#define ADDCARRY(x) (x > 65535 ? x -= 65535 : x)` `:20` — the carry folding our `ADDC` loop implements |
| `0x3F942230` (byte compare with count) | **`bcmp()` `lib/libc/string/bcmp.c:18`** | `bcmp(b1, b2, length)` — our function takes two pointers and a count and compares bytes |
| `0x3FB2C000` (16/32-bit swap) | **`ntohl()`/`ntohs()` macros**, `sys/pmax/include/endian.h:48` | `#define ntohl(x) (x)` on the big-endian pmax; the firmware implements the swap as a function because its build needs real byte swapping |
| `0x3FB2C140` (compare helper) | **firmware-specific** | a two-value compare used by `ifa_ifwithaddr`-style scans and by the codec pair; in BSD the equivalent is the inline `equal()` macro (`if.c:147`), not a callable |
### 58.5 Coverage after 58
Every function the report gives an address for now has either a quoted
counterpart or an explicit firmware-specific entry. The only rows marked
firmware-specific are the deferred I/O glue (56.2), the field-codec table and
protocol-id dispatcher (57.1–57.3), the lock wrapper `0x3F61B000`, the packet
accessor `0x3F61BFC0`, the queue post `0x3F60BD40`, and the compare helper
`0x3FB2C140`.
## 59. Coverage by function entry, and the remaining entry points
A raw grep of the report finds 225 distinct `0x3F…` addresses; only 66 appear in
sections 53–58. That is expected and not a coverage gap: most of the 225 are
**internal labels and call sites** (the ICMP type handlers inside `icmp_input`,
the `tcp_input` switch targets, instruction addresses inside a mapped function).
Mapping is per *function entry*. This section names the entry points that were
still missing.
### 59.1 Entry points mapped here
| ROM | counterpart | evidence |
|---|---|---|
| `0x3F604780` | **`tcp_slowtimo()` `sys/netinet/tcp_timer.c:76`** | walks the connection list and decrements timers (cells `0x1DA4D0`/`0x1DA4E0` in our reading) |
| `0x3F606210` | **`tcp_fasttimo()` `tcp_timer.c:51`** | the fast (delayed-ACK) timer, counter `0x1DA4F0` |
| `0x3F607C90` (device transmit method, `*(dev+0x160)`) | **`ether_output()` `sys/net/if_ethersubr.c:69`** | validates the destination/route and calls the interface output |
| `0x3F6084E0` (send, 0x32 clamp) | `ether_output()` framing; BSD pads to the Ethernet minimum | the firmware clamps explicitly to `0x32` (50) rather than padding an mbuf |
| `0x3F6105E0` (arpresolve caller) | **`arpresolve()`/`arpwhohas()` `if_ether.c:261`/`:202`** | emits the `arpresolve: no free entry` diagnostic |
| `0x3F6B0160` (trim / pull the header up) | **`m_pullup()` `sys/kern/uipc_mbuf.c:440`** | ensures `len` bytes are contiguous before parsing |
| `0x3FDDCBE0` (`io5800_5900_init`) | **`leinit()`/`lereset()` `if_le.c:429`/`:359`** | CSR1/CSR2 = init block, CSR3 = 0, CSR0 = INIT (section 52.3) |
| `0x3FDD8E80` (second CSR0 handler, diagnostics) | **`leintr()` `if_le.c:501`** | same role as `0x3F9D5B40`, registered for the diagnostics path (section 6) |
| `0x3FDD74B0` (Ethernet address ROM reader) | the MAC read at probe time, `leprobe()` `if_le.c:146` | reads the board's address ROM |
### 59.2 Entry points that are firmware-specific
| ROM | reason |
|---|---|
| `0x3F615610` (MAC formatter, `xx:xx:xx:xx:xx:xx`) | BSD prints link addresses inline in log messages; there is no formatter function to map to |
| `0x3F62BF30`, `0x3F62C4B0` | helpers that prepare the output block inside the IP transmit path; no line-level counterpart |
| `0x3F6AC3C0`, `0x3F6AC880`, `0x3F6ACE40`, `0x3F6FC200` | the firmware's own OS primitives (lock/unlock, deferred work) |
| `0x3FB33870`, `0x3FB338D0` | lock/unlock; the BSD analogue is `splnet()`/`splx()`, inlined there rather than called |
| `0x3FB2E040`, `0x3FB44400` | buffer helper / memcpy-style primitive |
| `0x3F61B000`, `0x3F61BFC0`, `0x3F60BD40`, `0x3FB2C140` | as listed in 56.2 and 58.3/58.4 |
### 59.3 Verification method
Coverage was checked mechanically against the report text:
```sh
python3 - <<'PY'
import re
s = open('harness/mame/NETWORK_REPORT.md').read()
sec = s[s.index('## 53.'):]
a = re.compile(r'0x3F[0-9A-F]{6}')
print(len(set(a.findall(s))), len(set(a.findall(s)) & set(a.findall(sec))))
PY
```
which reports 225 addresses in the report and 66 inside the mapping sections.
The difference is dominated by internal labels; every one of those belongs to an
entry point that *is* in a row. The entry points themselves are enumerated above
and in sections 53–58.
## 60. Machine-generated coverage: every entry point, not a spot check
Section 59.3's 30-address check was a spot check and 59.1's "internal labels"
claim was an assertion. This section replaces both with a mechanical
classification of **every** address the report mentions.
### 60.1 Method
```python
unmapped = all_report_addresses - addresses_in_sections_53_59
entries = sites whose first instruction is MMTM (0x098f)
covered = unmapped addresses whose enclosing entry is in a mapping row
orphans = the rest -> their enclosing entry needs a row
```
Result:
```
report addresses 225
in a mapping row 82
unmapped 143
of which covered by a mapped
enclosing function 102
in an unmapped entry 41 -> 26 distinct entry points
```
So the honest answer to "are all found addresses mapped?" was **no** — 26
function entries were missing. They are listed and mapped below.
### 60.2 The 26 entries
| ROM | status |
|---|---|
| `0x3F60FB60` | **ARP resolve** — contains the `arpresolve: no free entry` log site `0x3F610660` → `arpresolve()` `if_ether.c:261` |
| `0x3F623180` | **interface lookup by network** → `ifa_ifwithnet()` `sys/net/if.c:189` |
| `0x3F60EE00` | deferred ARP callback (registered at `0x3F614340`) — firmware-specific deferred work |
| `0x3F6BD9F0` | **`tcp_respond()` `sys/netinet/tcp_subr.c:112`** — builds the ACK from the SYN_RECEIVED handler |
| `0x3F9DAFF0` | **`leerror()` `sys/pmax/dev/if_le.c:972`** — called with `(5)` from the ISR on `ERR` |
| `0x3F9D8A50` | the TINT half of **`leintr()` `if_le.c:501`** (TX completion) |
| `0x3F9D27D0` | the driver's **`leinit()`/`lereset()`** path (contains the CSR/init-block writes) |
| `0x3F602DB0` | the receive function — **entry of the deferred-RX glue** (see 60.3) |
| `0x3F604C30` | contains `0x3F606010`/`0x3F606210` → the **`tcp_fasttimo()`/`tcp_slowtimo()`** pair (`tcp_timer.c:51`/`:76`) |
| `0x3F5FFD90`, `0x3F60C100`, `0x3F6166D0`, `0x3F623940`, `0x3F6448E0`, `0x3F64FE90`, `0x3F653610`, `0x3F65A0C0`, `0x3F660000`, `0x3F662410`, `0x3F675C40`, `0x3F9E14C0`, `0x3FA7C050`, `0x3FE21120` | **firmware-specific** — helpers inside the protocol/OS layers with no 4.4BSD counterpart found (each was checked for a prologue and for call sites; the codec dispatcher `0x3F660000` and the protocol-id dispatcher's default `0x3F65A0C0` are covered by 57.1–57.3) |
| `0x3F634600` | **firmware-specific** — the default IP-protocol handler that dumps the packet (BSD's equivalent is the silent `bad:` drop at `ip_input.c:368`, which has no body to map to) |
| `0x3FA932D0`, `0x3FA935D0` | **firmware OS interrupt stubs** (`0x3FA935D0` calls the diagnostics handler `0x3FDD8E80`) |
### 60.3 Correction: the RX function entry is `0x3F602DB0`
Sections 30.1 and 56.2 call `0x3F602E00` the receive entry. The prologue scan
shows the function starts at **`0x3F602DB0`** (`mmtm sp,a9,a11,a13`); `0x3F602E00`
is its third instruction. The mapping row should therefore be
`0x3F602DB0` (deferred-RX glue), with `0x3F602E00` as an interior label.
### 60.4 Coverage statement (replaces 56.3/57.5/58.5/59.3)
> Every function **entry point** the report mentions now has a mapping row:
> a quoted 4.4BSD counterpart where one exists, otherwise an explicit
> firmware-specific entry naming why. Interior labels and call sites are
> covered by their enclosing entry, and that is checked mechanically by the
> script in 60.1 rather than by inspection.
The remaining honest caveat: for the fourteen entries marked firmware-specific
in 60.2 I did not find a counterpart; that is a statement about my search, not a
proof that none exists.
## 61. CONFIRMED ON REAL SILICON: the corrected payload works
`harness/udpstream/build/lance_fixlen.coff` was loaded on the VT1200 (via
`dwt/loader/hostload.py` over rdebug) and its panel read off the screen. The
panel encoding is `bits16()`: 16 blocks, left to right = bit 0..15, white = 1.
| panel row | y | white blocks | decoded | meaning |
|---|---|---|---|---|
| 1 | 670 | 1,2,5,6,8,9,10,11 | `0x7B3` | CSR0 after the TDMD kick |
| 2 | 700 | same | `0x7B3` | CSR0 sampled later |
| 3 | 730 | 9,10,16 | `0x8300` | TMD1 (OWN still set at the sample) |
| 4 | 760 | 9,10 | **`0x0300`** | **RMD1: OWN clear, STP\|ENP** |
| 5 | 790 | 3,4,5 | **`0x001C`** | **MCNT = 28 bytes** |
| 6 | 850 | (none) | — | TX-done indicator dark (TMD1.OWN still set) |
| 7 | 850 | 1st | **ok = 1** | payload bytes intact at the RX buffer +12 |
The five boxes down the left edge are `dot(0..4)`, the payload's progress marks;
all five lit means it reached the RX-completion call.
### 61.1 The result equals the MAME reference
```
MAME lance.coff: csr0=0x7B3 ok=1 rmd1=0x0300 mcnt=0x1C txmd1=0x8300
real lance_fixlen: csr0=0x7B3 ok=1 rmd1=0x0300 mcnt=0x1C txmd1=0x8300
```
`ok` is computed only if the received buffer still holds the transmitted pattern
(`0x88 0xB5 0xDE` at offset 12), so this is a **complete loopback through the
chip** on the machine, not merely a descriptor status.
### 61.2 What that settles
* The firmware's hardware-driving sequence is confirmed on real silicon: init
block, CSR1/CSR2/CSR3, `INIT`, `IDON`, `STRT`, ring arming, descriptor format,
`TDMD` — all of it behaves as the model and the disassembly said.
* The earlier on-machine failure (`CSR0 = 0x1BB`, `TMD1 = 0x8338`, no receive)
was **the payload's descriptor-length encoding**, exactly as section 50
diagnosed: the field must be the two's complement (`-len`, i.e. `0xFFE8` /
`0xFA0A`), and MAME hid the bug by ORing `0xF000` in `get_buf_length()`.
* `TMD1 = 0x8300` (OWN set) in both MAME and on the machine is a sampling
artefact of this payload, not a failure: transmit completion is proven by
`RMD1` (OWN clear), `MCNT = 28` and `ok = 1`.
### 61.3 Operational note
The payload re-initialises the LANCE, so the terminal's network stack dies while
it runs — the rdebug connection used to load it is lost, and the results are only
observable on the panel (or in memory if the machine is still up). The
`lance_restore.c` variant adds a "give the chip back to the OS" block (re-program
CSR1/CSR2 from `0x23E7A0`, `INIT`, poll `IDON`, `STRT|INEA`), but on this run it
did not bring the network back; the unit needed a power cycle. Fixing that is
open work, not a blocker for the result above.
### 61.4 The panel is now text, and the restore is instrumented
Reading 16 white boxes off a photograph of the screen is error-prone, so the
payload now draws **labelled hex text** instead:
```
CSR0 7B3 TMD1 8300 MCNT 001C REST 0042
CSR1 7B3 RMD1 0300 OK YES
```
A 5x7 glyph table (`0-9`, `A-F`, and the letters the labels need) is built **at
runtime** — this toolchain never runs `.cinit`, so a statically initialised table
would arrive as zeros, the same reason `harness/c-hello` builds its font in code.
Glyph pixels are 8 px wide (the VRAM write granularity) and 3 px tall, on a
32-pixel row pitch.
`REST` is new: the CSR0 read back **after** handing the chip to the OS, so a
failed restore is visible rather than inferred.
Note on verification: the panel coordinates and the 2048-byte VRAM stride come
from the real unit (they are the ones that displayed correctly there). MAME's
frame dump does **not** reproduce the panel — the OS repaints the screen
continuously there, and the real unit's VRAM layout differs slightly — so the
panel is verified on the machine, not from an emulator screenshot.
## 62. Correction: `0x23CC50` is the RDP pointer, not a CSR0 shadow
Section 10 (and the restore code written from it) treated cell `0x23CC50` as a
software shadow of CSR0 and wrote `0x0042` into it. That is wrong and it
**crashed the terminal with an illegal opcode** on the machine.
The disassembly is unambiguous:
```
3F9D5960 move @0x23CC50,a3,1 ; a3 = value of the cell = 0x05800000
3F9D5990 move *a3,a5,0 ; a5 = *(0x05800000) = *RDP = current CSR0
3F9D59B0 or a3=8,a5 ; a5 |= TDMD
3F9D59D0 move @0x23CC30,a5,1 ; a5 = second RDP alias (also 0x05800000)
3F9D5A00 move *a13,*a5,0 ; *RDP = a5
```
`0x23CC50` holds a **pointer** (`0x05800000`, matching the RAM dump in section 2),
and the driver reads the *live* CSR0 through it before OR-ing `TDMD`. There is no
shadow: the "preserved" bits are simply whatever the chip currently reports.
Consequences, all now fixed:
* writing `0x0042` into `0x23CC50` overwrites the RDP pointer, so the driver's
next CSR access targets address `0x0042` — the fault observed on the unit;
* the restore must write CSR0 through `RAP`/`RDP`, never through that cell;
* section 10's phrase "CSR0 = shadow | TDMD" should read "CSR0 = read-back | TDMD".
The restore path in `harness/udpstream/lance.c` now re-programs the chip with
`0x7F41` (the firmware's own init value: `INIT | INEA | all status bits`), polls
`IDON`, writes `0x0042`, and leaves the driver's cells untouched.
## 63. Crash cause: the marker cells ran into `.text`
Adding the text panel grew `.text` from `0x018C6140` to `0x018CACD0`, which
**swallows the marker block** at `0x018C8000`. The payload then wrote
`cell(0x160, …)` and friends straight into its own code, and the CPU executed
the overwritten bytes — an illegal opcode before any text was drawn (four
progress dots, blank panel).
That is a general hazard of this payload layout: `.text` grows upward from
`0x018C0000` while the markers were hard-coded at `0x018C8000`. They are now at
**`0x018D0000`**, clear of the end of `.text` by ~2.6 KB.
Any future growth should re-check `build/lance.map` against `MARK`.
## 64. Text panel on the unit: loopback reproduced, chip hand-back works, OS does not
Photo: `images/20261030_155855.JPG`. Panel as drawn by
`lance_fixlen.coff` (text build, `MARK` moved to `0x018D0000`):
```
CSR0 07B3 TMD1 8300 MCNT 001C REST 0073
CSR1 07B3 RMD1 0300 OK YES
```
and five progress dots down the left edge (the payload ran to the RX completion).
### 64.1 The loopback is reproduced
`CSR0 = CSR1 = 0x07B3`, `TMD1 = 0x8300`, `RMD1 = 0x0300` (OWN clear, STP|ENP),
`MCNT = 0x001C` (28 B = 24 + FCS), `OK = YES`. Identical to the earlier
white-box run (section 61) and to the MAME reference — the text build changes
nothing in the test itself.
### 64.2 `REST = 0x0073` — the chip *was* handed back correctly
```
0x0073 = INIT | STRT | TXON | RXON | INEA
(STOP clear, TDMD clear, INTR clear, IDON clear)
```
So after the test the payload re-programmed the chip from the firmware's own
init block (`0x7F41`), polled `IDON`, wrote `0x0042`, and the chip responded:
transmitter on, receiver on, interrupts enabled. **The `INIT` bit still being set
is normal** — MAME's own `0x7B3` has it too (section 47.2).
In other words the "give the chip back" step works at the chip level. It has
never been the chip.
### 64.3 What actually stays broken: the OS's Ethernet driver
The screen behind the panel shows the Terminal Manager with
**`Ethernet Port Error - 24`** at the bottom, and the network is unreachable
afterwards (ping fails, rdebug port 771 closed).
So the recovery failure is in the **firmware's own network stack**, not in the
LANCE: the driver noticed the chip disappear during the takeover, marked the port
failed, and does not re-initialise on its own even when the chip comes back
enabled.
Practical consequence, recorded so it is not re-litigated: **a LANCE takeover run
on this machine costs a power cycle.** The restore code stays (it is correct and
harmless), but the unit will not return to service by itself.
### 64.4 Where that leaves the hardware question
It is answered:
| question | result |
|---|---|
| does the firmware's init/ring/descriptor/TDMD sequence work on real silicon | **yes** (sections 61, 64.1) |
| was the old failure our payload or the chip | **our payload**: descriptor length must be `-len` (`0xFFE8`/`0xFA0A`) |
| can the chip be handed back to the OS from a payload | **yes at the chip level** (`REST = 0x73`), not at the driver level |
### 64.5 The stray white box in the photo
The photo shows one white box half over the `REST` row. That is the **old `ok`
indicator** (`rect(120, 850, 24, 24, 0xFF)`), left over from the white-box
layout: the text row `REST` sits at y 846-867 and the box at y 850-874, so they
overlap. The text panel already reports `OK YES/NO`, so both indicator boxes
(`ok` at x 120 and TX-done at x 160) have been removed. Cosmetic only — the
values in 64.1/64.2 are unaffected.
## 65. Correction: "Ethernet Port Error - 24" is a clean-boot condition
Section 64.3 used the banner `Ethernet Port Error - 24` on the screen behind the
panel as evidence that the firmware's network driver had been left broken by the
payload. **That is wrong**: the terminal shows this banner after a **clean boot**
as well, while its Ethernet port works normally (the unit answers ping and the
rdebug service on port 771).
So the banner is the firmware's own **Ethernet self-test result**, not a runtime
fault. It is worth noting for the LANCE work that this matches the emulator: the
firmware's `C - LANCE TEST` also fails in MAME (`LANCE_LOOPBACK_REPORT.md`, the
`RMD1_FRAM` mismatch), while ordinary traffic passes. In other words the
self-test's expectation is not met by this hardware either — pre-existing, and
unrelated to the takeover payload.
### 65.1 What that changes
* The banner is **not** evidence about the payload. Section 64.3's conclusion
("the driver noticed the chip disappear and marked the port failed") is
withdrawn.
* The claim "a takeover run costs a power cycle" therefore rests only on the
runs that **crashed** (the `0x23CC50` write and the marker/`.text` overlap).
For the current build — the one that produced `REST = 0x0073` — the unit was
never pinged after the run, so **end-to-end recovery is unverified**, not
disproved.
* The check is one command, after a successful (non-crashing) run:
```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.
## 67. Same payload on the real VT1200: send and receive both work
`harness/udpstream/lance_bcast.coff` was loaded on the unit with
`dwt/loader/hostload.py`, while the host sent `0x88B5` broadcasts at it from its
own NIC. The panel reads (some glyphs were missing at the time, see 67.2):
```
T D 0005 -> TXD = 5 frames transmitted
R F 0005 -> RXF = 5 frames received
ET 88B4/88B5 -> last ethertype: our own 0x88B5 broadcasts
MAC … -> last source MAC (row overlapped, see 67.2)
CSR0 0073 -> INIT|STRT|TXON|RXON|INEA: the chip is running
REST 0073 -> hand-back unchanged
```
plus five progress dots, i.e. the payload ran to the end.
### 67.1 What it shows
* **Transmit works on real silicon**: five descriptors armed by our code
completed. (`TXD` should be 6; one frame did not complete inside the payload's
wait window on the real chip, so the count is 5 — the same wait works in MAME
because the model services the demand immediately.)
* **Receive works on real silicon**: `RXF = 5` — five frames sent by the host
were delivered into our RX ring, and the early-stop threshold is exactly five,
which is why the number is 5 rather than "many". The recorded ethertype is
`0x88B5`, i.e. the frames the host was sending.
* `CSR0 = 0x0073` is a healthy chip (`STRT`, `TXON`, `RXON`, `INEA`), so the test
left it running.
This is the first time the payload has exchanged packets with a real peer, and it
does so with no ROM code: our init block, our rings, our descriptors, our
`TDMD` kicks.
### 67.2 Two display bugs found by reading the real panel
| symptom | cause |
|---|---|
| `T D` / `R F` instead of `TXD` / `RXF` | the glyph table had no **`X`** — `gi()` mapped it to an empty slot, so the letter simply did not draw |
| MAC row a smear of overlapping text | the three 4-digit MAC groups were drawn at x=312/384/456, but each group is 192 px wide, so they were painted on top of each other |
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.