# 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 `), e.g. `/tmp/opencode/ping_trace.lua`, `/tmp/opencode/win_trace.lua`. * Headless debugger note: **breakpoint `printf` actions print nothing**; use the `trace ` action instead. `bpset` addresses are bit addresses. * Register/ROM reads on the real unit: `dwt/solve.py --peek --len N`. * Ring/init-block dumps: `VT1200_DUMP_RAM=` 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(" 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(">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 :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.