Files
vt1200/harness/mame/NETWORK_REPORT.md
arti 8ff16b5b73 NETWORK_REPORT: machine-generated coverage -- 26 unmapped entries found and mapped
Replaces the 30-address spot check with a mechanical pass: every report address
is classified against the MMTM prologue table, so interior labels are shown to
be interior rather than asserted. 225 addresses, 82 mapped, 143 unmapped, of
which 102 fall inside a mapped function and 41 sit in 26 unmapped entries.

Maps those 26: arpresolve (if_ether.c:261) for 0x3F60FB60, ifa_ifwithnet
(if.c:189) for 0x3F623180, tcp_respond (tcp_subr.c:112) for 0x3F6BD9F0,
leerror (if_le.c:972) for 0x3F9DAFF0, the leintr TINT half for 0x3F9D8A50,
leinit/lereset for 0x3F9D27D0, the tcp timer pair for 0x3F604C30, and marks the
rest firmware-specific with the reason.

Also corrects the RX entry: the function starts at 0x3F602DB0, not 0x3F602E00.
2026-10-30 11:25:54 +02:00

174 KiB
Raw Permalink Blame History

VT1200 networking — how the firmware drives the LANCE

Reverse engineering notes, measurement and disassembly only. The ROM is untouched (md5 b46a3e442ccb86134d2356bd70d22fd2), no driver edits. Evidence for each claim is the command/address quoted next to it.

Address convention: bit addresses (TMS34010). Ghidra address = bit / 8, and in the Ghidra project the ROM is loaded at 0x07E40000 + file offset.


1. Hardware surface

item address evidence
LANCE register address port (RAP) bit 0x05900000 movi 0x5900000 in the only code that uses the immediate, the ISR at 0x3FDD8E80
LANCE register data port (RDP) bit 0x05800000 same site
Ethernet address ROM bit 0x06800000, 8 bytes = MAC + 2 checksum bytes reader 0x3FDD74B0; LANCE_REPORT.md
interrupt LANCE intr_out → INT1 LANCE_REPORT.md; INTENB bit 1 at 0x3FA8C340

Registers are 16-bit (move …,0). csr_w(reg, v) = write reg to RAP, then v to RDP; csr_r(reg) = write reg to RAP, read RDP.

2. The driver's device struct (found in a live RAM dump)

The driver does not hard-code the register addresses in its normal paths; it keeps them in a struct. On a live boot (RAM dump at frame 5500) the struct at bit 0x23CBF0 (byte 0x4797E) reads:

+0x00  u32 0x05900000   RAP base
+0x04  u32 0x05900000   RAP base (copy)
+0x08  u32 0x05800000   RDP base
+0x0C  u32 0x05800000   RDP base (copy)
+0x10  u32 0x06800060   Ethernet ROM pointer (0x6800000 + 8 bytes read)
+0x14  u32 0x0008FFC9   driver status
+0x16  u8[6] 08 00 2b 16 4b 5a   PADR (MAC, copied from the ROM)

How it was found: a byte search of the RAM dump for the little-endian values 0x05900000 / 0x05800000 returns byte offsets 0x4797E / 0x47986 (= bits 0x23CBF0 / 0x23CC30), and the MAC appears at 0x47996 (= struct + 0x16).

This is also why a static search for movi 0x5900000 finds only two sites: the normal driver loads the pointer from this struct.

3. Initialisation sequence (from the firmware's own code)

io5800_5900_init at bit 0x3FDDCBE0 (Ghidra 0x07FBB97C): loads the RAP/RDP immediates, then:

RAP=1  RDP=initblock>>0        ; CSR1
RAP=2  RDP=initblock>>16       ; CSR2
RAP=3  RDP=0                   ; CSR3 (bus config)
RAP=0  RDP=0x7F41              ; CSR0 = INIT + standard interrupt enables
... poll CSR0 bit 8 (IDON) ...
CSR0 = 0x0042                  ; STRT | INEA   (the OS's own start)

The OS's live path is the same sequence (LANCELOG in LANCE_REPORT.md: CSR1=0x0F80, CSR2=0, CSR3=0, CSR0=0x0001, poll CSR0=0x0181, then CSR0=0x0042).

4. Initialisation block

12 words / 24 bytes, read by the chip over DMA:

word field live value
0 mode 0x0000 (normal; our test used 0x8000 PROM / 0x0044 LOOP|INTL)
1-3 PADR (6 B) 08 00 2b 16 4b 5a
4-7 LADRF (8 B) 0000 0000 0008 0000
8 RX ring address low 16 0x0A80
9 RX ring high 8 + length in bits 13-15 0xE000 → length 1<<7 = 128
10 TX ring address low 16 0x0E80
11 TX ring high 8 + length 0xA000 → length 1<<5 = 32

Captured live from the running unit with peek at bit 0x7C00 (byte 0xF80, the address stored in the pointer cell 0x23E7A0):

00 00 08 00 2b 16 4b 5a 00 00 00 00 08 00 00 00 80 0a 00 e0 80 0e 00 a0

After boot the descriptors at byte 0x0A80 read 00 00 00 00 | 00 f0 | 00 00 per entry — address 0, flags 0, length 0xF000 (= 4096). So the ring is re-armed dynamically by the driver; it is not left statically owned.

5. Descriptors and DMA

8 bytes per descriptor:

+0  u16  buffer address low 16
+2  u16  flags | buffer address high 8   (OWN=0x8000, STP=0x0200, ENP=0x0100,
                                          ERR=0x4000, ...)
+4  u16  buffer length
+6  u16  message count (RX completion)

Buffer length encoding (C-LANCE, get_buf_length in MAME's am79c90.cpp:191):

length = -s16(0xF000 | data)      e.g. 64 B -> 0x0FC0, 1526 B -> 0x0A0A, 4096 -> 0xF000

DMA addresses are 24-bit byte addresses: buffer = (RMD1 & 0xFF) << 16 | RMD0; rings = (word9 & 0x1FFF) << 16 | word8.

6. Interrupt handling (corrected by trace)

Two different handlers exist; only one is live.

Live path. The trace shows the CPU being interrupted and the OS dispatching by handler table:

(interrupted at 3F579870, IRQ 0)
00000F60: JA  3F7144C0
3F7144C0: MMTM SP,A0-A3,A8
3F7144E0: MOVI 3F7151F0,A0        ; handler table
3F715040: SETF 8
3F715050: A3 = *0x1E9420; A3++    ; interrupt counter
3F7150C0: A1 = *A0                ; handler from the table
3F7150D0: CALL A1                 ; -> 3F9D5B40
3F9D5B40: MMTM SP,A7,A9,A11       ; LANCE handler

The LANCE handler 0x3F9D5B40 (803 calls in an idle-window trace):

a11 = *0x23CBF0                   ; RAP pointer (0x05900000)
*RAP = 0                          ; select CSR0
a7  = *0x23CC50                   ; RDP pointer #2 (0x05800000)
a9  = *RDP                        ; read CSR0
btst 6, a9                        ; INTR
jreq return                       ; not ours
a7 = *0x23CC30                    ; RDP pointer #1
*RDP = a9 ^ 0x40 ; *RDP = 0x40    ; clear INTR
btst 15 .. 8 on a9                ; ERR, MISS, RINT, TINT ...
   bit 10 set -> calla 0x3F9D62B0 ; RX poll
   bit 9  set -> calla 0x3F9D8A50 ; TX routine
   bit 8  set -> set 0x23EC90 = 1

Not live. 0x3FDD8E80 (the other CSR0 handler, reached only from the vector stubs 0x3FA93470/0x3FA935F0) has 0 hits in a 168 MB boot trace with 200 ICMP echoes. It belongs to another path (diagnostics), not the running stack.

Note the three RAP/RDP pointer cells: 0x23CBF0 = RAP, 0x23CC30 and 0x23CC50 = two RDP aliases (all 0x05900000 / 0x05800000 in the RAM dump). 0x23CC50 is also the CSR0 command shadow the init/TX code reads and ORs before writing.

7. What the traffic actually exercises

Differential trace: the same boot window traced with and without ICMP traffic (/tmp/opencode/no_ping.tr vs with_ping.tr), PC sets diffed. The PCs that appear only with traffic in the driver area are 0x3F9D90E0–0x3F9D9270 and 0x3F9D9400 — the protocol layer (TCP/IP flag and counter handling, cells 0x23CB70/0x23CB80/0x23ECxx/0x23EFxx), not the chip ring handling. The ring handling code also runs without traffic (it is the poll loop), which is why it is not in the diff.

8. Independent validation of the above against real silicon

A payload (harness/udpstream/lance.c) built from this description was tested in MAME loopback and reached:

ok=1   RMD1=0x0338 (OWN clear, STP|ENP)   MCNT=0x001C (28 = 24 + 4 FCS)   TMD1=0x0338

i.e. init block, ring addressing, descriptor format, length encoding, TX and RX DMA are all correct for the modelled chip.

On the real unit the same payload completes INIT → IDON → STRT (CSR0 = 0x1B3, then a stable 0x33), but:

  • TDMD stays set and the TX descriptor is never touched (0x8338),
  • the RX descriptors are never touched,
  • one earlier run showed MISS|ERR, i.e. the receiver did see frames but had no usable buffer.

This is the open question: the driver's dynamic ring arming (section 4) is not yet reproduced, and the chip does not service a transmit demand from a hand-built ring.

9. Reproduction / instrumentation

  • Boot trace: -autoboot_script traces (trace <file>), e.g. /tmp/opencode/ping_trace.lua, /tmp/opencode/win_trace.lua.
  • Headless debugger note: breakpoint printf actions print nothing; use the trace <file> action instead. bpset addresses are bit addresses.
  • Register/ROM reads on the real unit: dwt/solve.py --peek <bitaddr> --len N.
  • Ring/init-block dumps: VT1200_DUMP_RAM=<frame> and search the dump.

10. Ring arming and the TX kick (the part that matters for a takeover)

From the traffic trace and the ring-handler sites (code referencing the ring cells 0x23E7E0 RX / 0x23E820 TX), both rings are driven the same way.

RX arming — 0x3F9D36C0

a9  = *0x23EC50              ; list of receive buffers
a7  = a9 + 0xA0              ; buffer data = entry + 20 bytes
a11 = a7 >> 3                ; BIT address -> BYTE address   <-- key
a7  = *0x23EBB0 << 6         ; ring position * 64 bits (8-byte descriptors)
a12 = *0x23E7E0 + a7         ; ring base (BIT address 0x5400 = byte 0xA80)
*a12      = a11              ; buffer BYTE address (32-bit store)
setf 12; *a12(0x20) = 0xFDDA ; RMD2 = 12-bit length, -(550) => 550 bytes
*a12(0x18) = 0x80            ; byte 3 = OWN, set LAST
*0x23EBB0 = (pos + 1) & 0x7F ; 128-entry ring wrap

Two details our first payloads got wrong or approximated:

  • the buffer address is converted with srl 3 to a byte address before it goes in the descriptor;
  • the length is written with setf 12 (a 12-bit field), value 0xFDDA for a 550-byte buffer;
  • OWN is written as a single byte 0x80 at descriptor + 3, after the address and length.

TX kick — 0x3F9D57F0

a7  = a11 << 6               ; descriptor offset
a0  = *0x23E820 + a7         ; TX ring (BIT address 0x7400 = byte 0xE80)
a7  = *(a0 + 0x18)           ; byte 3 (RMD1 high byte)
a7 |= 0x80                   ; OWN
*(a0 + 0x18) = a7            ; own the descriptor
...
a7 = *0x23CBF0               ; RAP pointer
*a7 = 0                      ; RAP = 0
a5 = *0x23CC50               ; CSR0 command shadow kept by the driver
a5 |= 0x0008                 ; TDMD
*0x23CC30 = a5               ; RDP = shadow | TDMD

Correction — see section 40. The explanation originally written here ("the command bits are not set-only: writing 0x0008 alone clears STRT") is wrong. The AM79C90 model makes INIT/STRT/STOP/TDMD write-1-to-set and INEA read/write, so a bare TDMD disables interrupts but does not stop the transmitter. The firmware's shadow exists to preserve INEA; and the real-silicon CSR0 = 0x1BB decode (STRT=1, TDMD=1, TXON=1) shows a poll that never ran, which is a different problem entirely.

The transmit demand is sent as shadow | TDMD, never as a bare TDMD. The write must preserve the shadow's read/write bits (in particular INEA), and it matches the driver; our payload arms the descriptor at kick time and writes STRT|TDMD in one word.

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):

python3 - <<'PY'
import struct
rom=open('vt1200_v22.bin','rb').read()
base=(0x3F622580//8)-0x7E40000
for i in range(19):
    print(i, hex(struct.unpack("<I", rom[base+i*4:base+i*4+4])[0]))
PY

22.2 The echo handler rewrites the type — 0x3F6212A0

3F6212A0  sub   a7,a7
3F6212B0  movb  a7,*a10          ; <<< ICMP type := 0  (echo reply)
3F6212C0  jruc  0x3F621A30       ; shared reply path

Three instructions. a10 is the transport header pointer, so this is the 8 -> 0 rewrite that the whole investigation was looking for, and it does not recompute anything itself — the shared reply path does.

22.3 Shared reply path — 0x3F621A30

a7 = *(a13+0x20)                 ; length delta
a5 = *(a11+0x10)                 ; IP total length (byte 2)
a5 += a7 ; *(a11+0x10) = a5      ; adjust total length for the reply
*0x96840 += 1                    ; "replies sent" counter
a3 = *a10 << 5 ; a3 += 0x96560   ; per-ICMP-type counter (4 bytes/entry)
(*a3) += 1
calla 0x3F622A40(reflect args)

a11 is the IP header (byte 2 = total length), a10 the ICMP header (type). Two statistics tables are involved: per-type counters at 0x96560 (indexed by type, 4 bytes per entry) and the reply counter 0x96840.

22.4 Reflect/send — 0x3F622A40 (the whole 0x3F622A40..0x3F623170 body)

a9  = arg (IP header / packet)
*a13(0x40) = (*a9 << 2) - 20      ; header/length arithmetic
*(a13+0x00) = *(a9+0x80)
*(a9+0x80)  = *(a9+0x60)
a10 = *0x96AC0                    ; interface list head
loop: match *(a10+0x20) against the packet's interface
      test bit 1 of *(*(a10+0x100)+0x40)   ; interface flags (up/broadcast)
      ...
calla 0x3F623180                  ; route/interface resolve
calla 0x3F62BF30                  ; prepare output block
calla 0x3F62C4B0                  ; prepare output block
3F622EA0  movi 0xFF,a11
3F622EC0  movb a11,*(a9+0x40)     ; <<< TTL (byte 8) := 255
calla 0x3F623350                  ; ICMP checksum, then ip transmit

So the reply is sent out of the interface the request arrived on: the interface list at 0x96AC0 is walked, the interface flags word (iface+0x100 -> +0x40, bit 1) decides whether it is usable, the route is resolved, and the TTL is set to 255 — the ordinary initial TTL of a freshly generated IP packet. All of this is inside 0x3F622A40 (epilogue at 0x3F623150/0x3F623170), not in ip-output.

22.5 Transport checksum, then IP transmit — 0x3F623350

a12 = a9 & 0xFFFFFC00            ; packet block base (1024-bit aligned)
a10 = (4-bit field at a9) << 2    ; setf 4 -> read nibble, scale by 4
a5  = *(a12+0x20) + a10 ; store   ; block offset updated
a11 = a12 + (*(a12+0x20) << 3)   ; transport header = block + offset
*a11(0x10) = 0                   ; <<< zero the checksum field (byte 2 of ICMP)
calla 0x3FB2C290(*a9(0x10)-a10, a12)  ; ones-complement sum helper (section 15)
*a11(0x10) = a8                  ; <<< store the computed checksum
...
calla 0x3F62EA00                 ; IP transmit finalisation

This is the ICMP checksum: field at byte 2 of the transport header, zeroed, summed by 0x3FB2C290, stored back. (Section 21's receive-side calls to the same helper at 0x3F6206D0/0x3F6235F0/0x3F625B40/0x3F62FFC0 are the matching verification on the way in; 0x3F623350 is the transmit side.)

22.6 IP transmit finalisation — 0x3F62EA00

Args: a9 = IP header, a11 = device/interface, a12 = packet block. Function extent confirmed in the ROM: prologue mmtm sp,a0,a2,a3,a5,a7,a9,a10,a11,a12,a13 at 0x3F62EA00, a single rets 2 at 0x3F631B00, so everything between — including the fix-up block and the device call — is one function. The header fix-up code sits at 0x3F62FEB0:

3F62FEB0  a7 = *(a9+0x10)  ; IP total length   (byte 2)
3F62FEE0  calla 0x3FB2C000 ; htons
          *(a9+0x10) = a8
3F62FED0  a7 = *(a9+0x30)  ; flags + fragment offset (byte 6)
3F62FF00  calla 0x3FB2C000 ; htons
          *(a9+0x30) = a8
3F62FF60  sub a7,a7 ; *(a9+0x50) = 0    ; <<< IP header checksum (byte 10) := 0
3F62FFC0  calla 0x3FB2C290(a0, a12)     ; compute
3F630000  *(a9+0x50) = a8               ; <<< store IP header checksum
3F630030  push args
3F630070  a5 = *(a11+0x160)             ; <<< device transmit method
3F630090  call a5                       ; -> 0x3F607C90

Offsets are bit offsets, so 0x10/0x30/0x40/0x50 are bytes 2, 6, 8 and 10 — total length, fragment word, TTL and header checksum: an ordinary IPv4 header fix-up, byte-swapping the two network-order words and recomputing the checksum.

22.7 The device transmit method is a pointer in the device struct

The trace shows the dispatch literally (icmpw.tr, around line 3614):

3F630070: MOVE   *A11(160h),A5,1     ; device + 0x160 = transmit method
3F630090: CALL   A5
3F607C90: MMTM   SP,A5,A7,A9-A11,A13 ; the method

0x3F607C90 then checks a device flag word (*(a9+0x40) & 0x41 == 0x41), byte-swaps its own checksum field, sets the minimum length 0x32, and calls the driver send 0x3F6084E0 — which is the function whose 0x32 clamp and wrapper call were documented in section 21. The full stack is therefore:

application / sockets
  -> ip_output 0x3F62EA00            (IP header, checksum, route)
    -> device->transmit  *(dev+0x160) = 0x3F607C90
      -> driver send 0x3F6084E0      (request build, 0x32 clamp)
        -> wrapper 0x3F9DC890
          -> driver command entry 0x3F9D4BC0  (ring arm)
            -> 0x3F9D57F0  CSR0 = shadow | TDMD

22.8 What this settles, and what it does not

Settled with trace + disassembly evidence:

  • the ICMP type test is a 19-entry jump table on the type byte (0x3F622580), reached by jr with the 8-bit field size already set, and the earlier "no CMPI #8" finding was an encoding error in my search (CMPI immediate is one's-complemented);
  • the echo request is answered by movb 0,*(icmp+0) in 0x3F6212A0, then the shared reply path adjusts the IP total length and counts the reply;
  • the reply's transport checksum is computed at transport-header byte 2 with the same helper used for receive-side verification;
  • the IP transmit finalisation (0x3F62EA00) byte-swaps total length and the fragment word and recomputes the IP header checksum at byte 10 (TTL 255 is set by the reflect function 0x3F622A40);
  • the protocol layer reaches the driver through a function pointer at device+0x160, not a fixed call;
  • the driver's transmit path is the shadow | TDMD kick of section 10.

Not settled: the internal TCP state machine (the estab/listen transitions behind 0x3F635910 and 0x3F645170), the socket-API binding table itself (entry points listed in section 19 and dwt/notes/TCPIP.md), and the routing/ARP tables behind 0x96AC0 and 0x3F6156B0. The hardware half — whether real silicon services TDMD with the corrected command — is still unverified because the unit is down.

The dispatch itself, and the TCP and UDP entry paths with their checksum, list-walk and delivery calls, are in section 23.

22.9 Receive-side entry: checksum verification before the type dispatch

The receive half of the same function is visible in the trace just before the dispatch (lines 2736-2762 of icmpw.tr):

3F6206D0  calla 0x3FB2C290        ; ICMP checksum over the received message
3F620700  a5 = a8
3F620710  jreq  0x3F6207B0        ; checksum OK -> continue
3F620720  *0x96800 += 1           ; <<< ICMP checksum-error counter
3F620790  jruc  0x3F6229A0        ; drop
3F6207B0  ...
3F6209B0  setf 8
3F6209C0  a7 = *a10 ; subk 0x12 ; jrhi drop    ; type range check
3F620A20  a7 += 0x96860 ; (*a7) += 1            ; received-per-type counter
3F620AC0  jr 0x3F6224E0                        ; -> the jump-table dispatch

So the same helper 0x3FB2C290 verifies the ICMP checksum on the way in (counter 0x96800, and 0x96820 for malformed/unsupported types) and computes it on the way out (0x3F623350), which is a consistent ones-complement sum used for both directions. The whole receive path — checksum verify, type counter, type dispatch, per-type handlers, reply build — is one function spanning 0x3F620xxx..0x3F622A30 (epilogue 0x3F622A10: mmfm sp,a3,a5,a7,a9,a10,a11,a12,a13; rets 2); its entry and the IP-protocol test that routes into it are both pinned in section 23: the function is 0x3F61FFA0 (ICMP input) and it is entered through the protocol dispatch table at 0x1E0F70/0xD6BE0, keyed on the IP protocol byte.

Counters seen so far, all in RAM:

cell meaning
0x96800 ICMP checksum errors (receive)
0x96820 malformed / unsupported ICMP
0x96840 ICMP replies sent
0x96860 received per ICMP type (4 bytes/entry, type << 5)
0x96560 sent per ICMP type (4 bytes/entry, type << 5)

23. IP protocol dispatch, TCP and UDP input

This is the layer above ICMP: how the stack decides which transport handler gets a received IP packet.

23.1 The dispatcher — 0x3F6270F0

3F6270F0  setf  8,0,0
3F6270F0  a5 = *(a9+0x48)        ; <<< IP protocol byte (byte 9)
3F627110  a5 <<= 3               ; 1 byte per table entry
3F627120  a5 += 0x1E0F70         ; protocol -> index table (RAM)
3F627150  a5 = *a5,0             ; index
3F627160  a7 = 0x170
3F627180  mpys a7,a5             ; index * 0x170 bits (46 bytes per block)
3F627190  a5 += 0xD6BE0          ; protocol block array (RAM)
3F6271C0  a5 = *(a5+0x50)        ; <<< handler pointer (block byte 10)
3F6271E0  call  a5

Two RAM structures drive it: a 256-entry byte table at bit 0x1E0F70 (protocol -> block index) and an array of 46-byte protocol blocks at bit 0xD6BE0.

23.2 The table, read from a live RAM dump

From ramdump3/vt1200-ram-5500.bin (bit address -> byte bit/8):

table 0x1E0F70:  03 04 03 03 03 03 02 03 ... 03 01 03 ...
proto:            0  1  2  3  4  5  6  7 ... 16 17 18 ...
proto index block (bit) handler
1 ICMP 4 0xD71A0 0x3F61FFA0 ICMP input (section 22)
6 TCP 2 0xD6EC0 0x3F63A340 TCP input
17 UDP 1 0xD6D50 0x3F6540C0 UDP input
everything else (253 values) 3 0xD7030 0x3F634600 default / unreachable

Block layout, verified in all four blocks (UDP block bytes 0..13 shown):

02 00 80 74 | 0d 00 | 11 00 | 03 00 | c0 40 65 3f
             ^+4      ^+6      ^+8     ^+10 handler = 0x3F6540C0
                       +6 = 17 = UDP          (TCP 6, ICMP 1, default 255)

So a protocol block carries its own protocol number at byte 6 and its input function at byte 10 — the dispatcher does not need the number once it has the block.

23.3 Trace evidence

trace dispatches (3F6270F0) ICMP 3F61FFA0 TCP 3F63A340 UDP 3F6540C0 default 3F634600
icmpw.tr (ping flood) 344 343 0 1 0
rttcp2.tr (80 TCP connects + UDP) 56 0 44 12 0

56 = 44 + 12 exactly, i.e. every dispatch in the second trace is accounted for by one of the two handlers, and the ICMP trace sends essentially all traffic to the ICMP entry. rttcp2.tr was recorded in real time (no -nothrottle), so wall-clock traffic from the host lands inside the traced frame window.

23.4 TCP input — 0x3F63A340 .. 0x3F643C40

One function (prologue mmtm sp,a0,a1,a3,a5,a7,a9,a10,a11,a12,a13, single rets 2 at 0x3F643C40). Head of the function:

3F63A340  mmtm  sp,a0,a1,a3,a5,a7,a9,a10,a11,a12,a13
3F63A430  *0x97590 += 1            ; TCP packets received
a10 = packet ; a5 = a10 + (*(a10+0x20)<<3) ; a9 = a5
3F63A510  setf 4 ; a5 = *a9        ; 4-bit IHL
3F63A530  subk 5,a5 ; jrls ...     ; IHL >= 5 words
3F63A5B0  length checks (0x7c, 0x28)
3F63A660  calla 0x3F6B0160         ; pull the header up / trim
3F63A700  *0x97630 += 1            ; TCP error counter

Ordered call sequence for the first TCP segment (a SYN to port 6000) taken from rttcp2.tr — 8,835 traced instructions, 233 calls:

3F63A980 -> 3FB2C000   htons (ports)
3F63AA20 -> 3FB2C290   <<< TCP checksum verification
3F63B160 -> 3F6AC3C0   lock
3F63B360 -> 3FB44400   queue/memory helper
3F63B510 -> 3FB44400
3F63B670 -> 3FB2C140   compare helper
3F63B6F0 -> 3FB2C140   compare helper
3F63B790 -> 3FB2C000   htons
3F63B830 -> 3FB2C000   htons
3F63B9F0 -> 3F61E490   <<< TCB / connection list walk
3F63BC80 -> 3F6BE610
3F6500E0 -> ...
3F653230 -> 3F61B000   list operation
3F653320 -> 3F64BC70
3F652630 -> 3F635910   connection processing

Per-edge counts over all 44 segments:

44  3F63A980 -> 3FB2C000   htons
44  3F63AA20 -> 3FB2C290   TCP checksum
44  3F63B670 -> 3FB2C140   compare
44  3F63B6F0 -> 3FB2C140   compare
44  3F63B790 -> 3FB2C000   htons
44  3F63B830 -> 3FB2C000   htons
44  3F63B9F0 -> 3F61E490   TCB list walk
33  3F6431F0 -> 3F635910   per-connection processing
33  3F6432D0 -> 3F645170
22  3F642BF0 -> 3F6ADF20   logging
22  3F640EA0 -> 3F6C30F0
11  3F63B160 -> 3F6AC3C0   lock
11  3F63C450 -> 3F61AB30
11  3F63C760 -> 3F61BFC0
11  3F63C8C0 -> 3F64A710
11  3F63CA90 -> 3F643C50
11  3F63F780 -> 3F6448E0

So every segment gets ports byte-swapped and a checksum verified, is matched against the connection table by the list walk 0x3F61E490, and 11 of the 44 went on to the data-delivery path (0x3F61AB30, 0x3F61BFC0, 0x3F6448E0) while 33 took the connection-state path.

23.5 UDP input — 0x3F6540C0 .. 0x3F655070

Head of the function (mmtm sp,a3,a5,a7,a9,a10,a11,a13), and the calls it makes for all 12 datagrams in the same trace:

3F654440 -> 3FB2C000   htons (ports)            x12
3F6547F0 -> 3FB2C290   <<< UDP checksum         x12
3F654AA0 -> 3F61E490   <<< socket/PCB list walk  x12
3F654B40 -> 3F61AB30   socket delivery prep     x12
3F654C60 -> 3F61EC00   deliver to socket         x11
3F655000 -> 3F6ADF20   log / drop                x1

12 datagrams, 11 delivered to a bound socket, 1 unmatched (the probe sent to a closed port) and logged. The same checksum helper 0x3FB2C290 verifies UDP here as it verifies ICMP and TCP — one ones-complement routine for all three.

23.6 Where the transport layer hands back to the driver

Both TCP and UDP delivery paths funnel into the reply/output machinery of section 22: 0x3F61EC00/0x3F61B000 lead to 0x3F622A40 (reflect), 0x3F623350 (transport checksum), 0x3F62EA00 (IP transmit finalisation) and the device method *(device+0x160). A UDP datagram in rttcp2.tr shows exactly that continuation:

3F654440 -> 3FB2C000   htons
3F6547F0 -> 3FB2C290   UDP checksum verify
3F654AA0 -> 3F61E490   socket lookup
3F654C60 -> 3F61EC00   deliver
3F61F350 -> 3F6AC3C0   lock
3F61FA50 -> 3FB2C000   htons
3F61FE80 -> 3F622A40   <<< reply: reflect/send path
3F623080 -> 3F623350   transport checksum
3F6238C0 -> 3F62EA00   <<< IP transmit finalisation

So the complete receive-to-transmit loop is closed: LANCE interrupt -> RX poll -> IP protocol dispatch -> transport input -> socket -> output -> IP transmit -> device method -> ring -> TDMD.

23.7 Repro and one tooling trap

Read the dispatch tables from a RAM dump:

python3 - <<'PY'
import struct
d=open('vt1200-ram-5500.bin','rb').read()
tab=d[0x1E0F70>>3:(0x1E0F70>>3)+256]
for p in (1,6,17,0):
    idx=tab[p]; blk=0xD6BE0+idx*0x170
    h=struct.unpack("<I", d[(blk>>3)+10:(blk>>3)+14])[0]
    print("proto %d idx %d block %#x handler %#x" % (p,idx,blk,h))
PY

Trap worth recording: a stale MAME instance keeps the tap device and happily answers pings and TCP connects on 192.168.99.2. Two runs produced traces with zero network activity while the host was successfully connecting — the traffic was going to an orphaned instance that was not the one being traced. Check pgrep -af mame0289/vt1200 before trusting a "no packets" trace.

24. The TCP state machine

The TCP input function of section 23.4 (0x3F63A340..0x3F643C40) contains the state machine: one state word in the TCB, two jump tables over it, and the transition code. All evidence below is ROM disassembly plus execution counts from rttcp2.tr (44 TCP segments over 11 completed connections).

24.1 The state field

The state is a 16-bit word at TCB bit offset 0x40 (byte 8). Every write to it in the TCP input function, with its immediate:

site value written state
0x3F63BE90 movk 1 LISTEN
0x3F63D980 movk 3 SYN_RECEIVED
0x3F63D4F0 movk 4 ESTABLISHED
0x3F63F740 movk 4 ESTABLISHED (from the SYN_RECEIVED handler)
0x3F6413F0 movk 10 TIME_WAIT

and the read that drives both dispatchers is the same field:

3F63F290  a8 = *(a11+0x40)     ; state
3F63F2B0  subk 3,a8
3F63F2C0  cmpi 7,a8 ; jrhi default   ; states 3..10 index the table

24.2 Two dispatch tables over states 3..10

Table A at 0x3F63F350 (reached at 0x3F63F340), table B at 0x3F6417C0 (reached at 0x3F6417B0). Both are 8 entries of 4 bytes, indexed by state - 3, i.e. states 3..10. Read from the ROM image:

state table A table B
3 SYN_RECEIVED 0x3F63EFE0 0x3F63F570
4 ESTABLISHED 0x3F63F060 0x3F63F980
5 CLOSE_WAIT 0x3F63F060 0x3F63F980
6 FIN_WAIT_1 0x3F63F060 0x3F63F980
7 CLOSING 0x3F63F200 0x3F63F980
8 LAST_ACK 0x3F63F200 0x3F63F980
9 FIN_WAIT_2 0x3F63F060 0x3F63F980
10 TIME_WAIT 0x3F63F200 0x3F63F980

States 0, 1, 2 (CLOSED, LISTEN, SYN_SENT) fall out of both tables through jrhi (state-3 is unsigned-large): table A's default is 0x3F63F450, table B's default is 0x3F6418C0.

24.3 What actually executed

3F63F2C0 / 3F63F340  (table A dispatch)      0   <- never: every state seen was < 3
3F63F450             (table A default)      33
3F641730 / 3F6417B0  (table B dispatch)     33
3F6417B0 targets:    3F63F570 x11             <- SYN_RECEIVED
                     3F63F980 x22             <- established-family states
3F63F570 (SYN_RECEIVED handler)             11
3F63F980 (established handler)              33

The per-segment flow is a perfectly repeating 4-segment cycle (11 connections × 4 = 44 segments):

seg 0  alloc/copy -> table-B default -> alloc/copy -> tcp-output -> alloc/copy
seg 1  A-default -> B:SYN_RECEIVED -> B:other -> B-default -> alloc -> output -> alloc
seg 2  A-default -> B:other -> ... (data, several outputs)
seg 3  A-default -> B:other

and the state writes inside those segments:

segment state write observed count
0 (SYN) 0x3F63BE90 → state = LISTEN 11
1 (handshake ACK) 0x3F63F740 → state = ESTABLISHED 11
2 (data) none; data counter 0x3F63FA90 11

So a connection is seen going CLOSED → LISTEN → (SYN_RECEIVED) → ESTABLISHED, 11 times, which is exactly the number of completed TCP connections in that run.

24.4 The handlers

SYN_RECEIVED — 0x3F63F570 (state 3, the path that completes the handshake):

3F63F570  a7 = *(a11+0x120)      ; TCB send-sequence field
3F63F590  a5 = *(a9+0x0E0)       ; segment sequence field
3F63F5B0  cmp ; jrgt 0x3F643460  ; out of window -> drop
3F63F5E0  a7 = *(a9+0x0E0) ; a5 = *(a11+0x280) ; cmp ; jrgt drop
3F63F650  *0x972B0 += 1          ; SYN_RECEIVED segment counter   (x11)
3F63F6F0  calla 0x3F6BD9F0
3F63F720  movk 4,a7
3F63F740  *(a11+0x40) = 4        ; <<< state := ESTABLISHED        (x11)
3F63F780  calla 0x3F6448E0       ; send (ACK)
3F63F7C0  window arithmetic on *(a11+0xC0)

Established family — 0x3F63F980 (states 4,5,6,7,8,9,10):

3F63F980  a7 = *(a9+0x0E0) ; a5 = *(a11+0x120) ; cmp ; jrgt 0x3F6400C0
3F63F9E0  a7 = *(a9+0x50) ; jrne 0x3F640070   ; segment length/flags
3F63FA20  a7 = *(a9+0x110) ; a5 = *(a11+0x1E0) ; cmp ; jrne 0x3F640070
3F63FA90  *0x97790 += 1                        ; data segment counter   (x11)
3F63FB00  a5 = *(a11+0x50) ; jreq ...

States below SYN_RECEIVED — 0x3F6418C0 (table B default; the SYN path):

3F6418C0  btst 4,a12                 ; segment flag bit 4 = ACK
3F6418D0  jreq 0x3F641DB0
3F6418E0  a7 = *(a11+0x180) ; a5 = *(a9+0xC0) ; cmp ; jrlt 0x3F641AD0
3F6419A0  a7 = *(a11+0x1A0) ; a5 = *(a9+0xE0) ; cmp ; jrlt 0x3F641AD0
...

24.5 TCB fields used by the machine

Only the fields the state code actually touches are listed; a11 = TCB, a9 = parsed segment:

TCB offset (bit) use
0x40 (byte 8) state (24.1)
0x50 (byte 10) flags/queue marker (jreq gates in 0x3F63F980)
0xC0 (byte 24) sequence field used in window arithmetic
0x120 send-sequence, compared against segment +0xE0
0x180 receive-sequence, compared against segment +0xC0
0x1A0 receive-sequence, compared against segment +0xE0
0x1E0 compared against segment +0x110
0x280 send-sequence bound, compared against segment +0xE0
0x2A0 alternative bound (min/max selection)
0x2D0 store in the SYN path

Segment (not TCB) fields: +0x50 length, +0xC0 and +0xE0 sequence words, +0x110 acknowledgement/sequence word.

Two 32-bit sequence spaces are maintained in the TCB and validated against the segment before any state change — the jump to 0x3F643460 on failure is the drop path.

24.6 The two helper calls seen in every segment

0x3F635910  "alloc/copy"   44 (from 0x3F652630, once per segment)
                           33 (from 0x3F6431F0), 33 (from 0x3F6489A0)
0x3F645170  "tcp output"   33 (from 0x3F6432D0), 22 (from 0x3F653A10)

0x3F635910 allocates from a pool at bit 0x97870: index from 0x97D70, address = 0x97870 + index*(1024+256) bits (160-byte records), then fills +0x20, +0x30, +0x40 and copies 26 words to +0x1B0. 0x3F645170 reads *(tcpcb+0x100) → +0xC0, then +0x280, +0x120, +0x140, +0x1E0, +0x2A0 and a flag byte at +0xD0 — the send/window arithmetic of an output segment.

24.7 Counters seen in this path

cell meaning observed
0x97590 TCP segments received 44
0x97630 TCP input errors —
0x972B0 segments handled in SYN_RECEIVED 11
0x97790 data segments handled in established states 11
0x97D70 pool index for 0x3F635910 —

24.8 Still open in TCP

The option parsing (MSS/window-scale) and the close path (0x3F63F200, 0x3F6413F0 writing TIME_WAIT, never exercised by this traffic), the exact meaning of the TCB fields beyond the sequence comparisons, and the socket binding table behind 0x3F61E490/0x3F61EC00.

25. Interface list, and a correction about the "ARP table"

25.1 The interface list at 0x96AC0

0x96AC0 is the head cell of the interface list. The lookup 0x3F623180 walks it and compares the requested address against *(iface+0x100):

3F623200  a9 = *0x96AC0
3F623230  jreq  not-found
3F623240  a11 = *(a9+0x100)          ; the interface's address object
3F623260  a7  = requested address
3F623280  cmp a7,a11 ; jrne next
3F6232A0  return a9                  ; found
3F6232C0  a9 = *(a9+0x200) ; jrne loop     ; <<< next interface

Read from the RAM dump (this boot ran the MAME harness, so the address is the harness's own 192.168.99.2; the structure is what matters):

iface at bit 0x7AA060:
  +0x00 (byte 0)  u32 0x00000002      family = AF_INET
  +0x04 (byte 4)  u32 0x0263A8C0      address      -> C0 A8 63 02 = 192.168.99.2
  +0x10 (byte 8)  u32 0x00000002      family (second address)
  +0x14           u32 0xFF63A8C0      broadcast    -> 192.168.99.255
  +0x20           u32 0x001DA570      pointer to the address/flags object
  +0x28           u32 0xC0A86300      network      -> 192.168.99.0
  +0x2C           u32 0xFFFFFF00      netmask      -> 255.255.255.0
  +0x200          u32 0x00000000      next interface (single-interface boot)

The address/flags object at 0x1DA570 is what 0x3F622A40 inspects (*(*(iface+0x100)+0x40), bit 1) before sending a reply: that bit is the interface's "usable" flag.

25.2 Correction: 0x1E0570 / 0x1E0600 are a hex-digit table, not ARP data

The routine 0x3F615610 was described in section 17 as an ARP/route table serialiser. It is a MAC-address formatter. The evidence is the table contents themselves:

bit 0x1E0600 (byte 0x3C0C0):
  30 31 32 33 34 35 36 37 38 39 61 62 63 64 65 66   "0123456789abcdef"

and the loop:

a7 = *a9 >> 4          ; high nibble
a7 <<= 3 ; a7 += 0x1E0600 ; *a11++ = *a7      ; -> hex character
a7 = *a9++ & 0xF       ; low nibble
a7 <<= 3 ; a7 += 0x1E0600 ; *a11++ = *a7
*a11++ = 0x3A          ; ':'  separator
inc a10 ; cmpi 6,a10 ; jrlt loop

Six bytes, each rendered as two hex digits, separated by : — the terminal's own MAC address in the form 08:00:2b:16:4b:5a, which is exactly what the Terminal Manager displays. The arpre…/mods …/glev… strings that follow in the image are unrelated version headers (in.c, in_pcb.c).

25.3 ARP: pointer to section 26

The ARP cache, the resolve path and the ARP input function were located afterwards — see section 26. The earlier statement here ("no ARP entry structure was found") was wrong because the entry is 16 bytes inside a hashed bucket, not a standalone structure, and because the fragment searched for was the gateway address in the wrong byte order.

Consequence for the next pass: ARP is handled below IP, on the Ethernet EtherType (0x0806) rather than the IP protocol byte, so it will not appear in the dispatch table of section 23. The next thing to pin is the EtherType demux in the receive path (0x0800 vs 0x0806 vs others), which will also give the 0x3F61FFA0-equivalent entry for ARP. Note when searching for those compares that CMPI stores its immediate one's-complemented (section 20), so 0x0806 appears as 0xF7F9 and 0x0800 as 0xF7FF.

26. ARP: the cache and the input path

Section 25.3 said the ARP cache was not found. That was wrong — it is found below, and the reason the earlier searches missed it is recorded in 26.5.

26.1 ARP is present (BSD strings)

The ROM carries the usual BSD ARP diagnostics, which is what identifies the implementation:

bit 0x3F6158D0   "arpresolve: no free entry"
bit 0x3F6159D0   "arp: ether address is broadcast for IP address ..."
bit 0x3F615C60   "...: %s."     (log format used by the ARP code)

and the code that references them:

bit 0x3F610660   movi 0x3F6158D0  -> "arpresolve: no free entry"
bit 0x3F6120A0   movi 0x3F6159D0  -> broadcast-MAC warning

26.2 The ARP cache — 19 buckets × 9 entries

0x3F614200 is the lookup/allocate routine:

3F614240  a7 = 0xFFFF ; *(a13) = a7        ; "best slot" seed
3F614280  a7 = *0x1E0550 ; jreq continue   ; ARP lock/flag cell
3F6142C0  *0x1E0550 = a11
3F614300  calla 0x3F6FC200(0x95DB0, 0x3F60EE00)   ; deferred work
L3F6143B0:
3F6143D0  a5 = *arg                        ; the IP address
3F6143E0  movk 19,a7
3F6143F0  modu a7,a5                       ; <<< hash = IP mod 19
3F614410  a7 = a5 << 3 ; a7 += a5          ; *9
3F614430  a7 <<= 7                         ; *128  => *1152 bits = 144 bytes
3F614450  a10 = 0x1DAF60 + hash*1152       ; <<< ARP cache base
L3F614620 (9-iteration walk, a10 += 0x80 bits per step):
      a7 = *(a10+0x58)                     ; byte 11: entry state
      jreq  -> empty slot, use it
      btst 2,a7 ; jrne next                ; bit 2: entry not usable
      a7 = *(a10+0x50)                     ; byte 10: 16-bit aging/LRU field
      cmp with the running best -> remember this entry

So the cache is 19 buckets keyed by IP mod 19, each holding 9 entries of 16 bytes (9 × 16 = 144 bytes), based at bit 0x1DAF60.

26.3 The entry layout

From the allocation path:

3F614820  a7 = arg (pointer to the IP address)
3F614840  *a10 = *a7               ; <<< entry +0  = IP address (32-bit)
3F614850  movk 1,a5
3F614860  *(a10+0x58) = 1          ; <<< entry byte 11 = state (1 = incomplete)
3F614880  return a10

and from the input path (0x3F6152A0, 0x3F6152D0) the state byte at +0x58 and the 16-bit field at +0x50 are updated when a reply arrives. MAC storage sits in the bytes between (the broadcast check at 0x3F6120A0 compares the sender's 6-byte address), but the exact offset was not pinned by this pass.

26.4 ARP input — 0x3F6148E0

3F6148E0  mmtm sp,a3,a5,a7,a9,a10,a11,a12,a13
3F614920  a9 = arg
3F614950  a7 = *(a9)                ; 16-bit field (ARP opcode area)
3F614960  subk 2,a7 ; jrne ...      ; <<< test for opcode 2 (reply)
3F614980  a7 = *(a9+0x80) ; jreq ...
...
3F6152A0  *(a10+0x58) = a5          ; entry state
3F6152D0  *(a10+0x50) = a5          ; entry aging field
3F6154F0  subk 2,a8                 ; second opcode test (request/reply split)
3F615800  cmpi 6,a10                ; 6-byte hardware-address loop

The cmpi 6 at 0x3F615800 is the Ethernet-address length loop — the point where the 6-byte MAC is copied into the cache entry.

26.5 Why the earlier searches missed ARP — and what is still open

Three searches came back empty, and each is a real fact worth recording:

search result
cmpi 0x806 anywhere in the ROM 0 sites (with the corrected one's-complement encoding)
word 0x00000806 in the frame-5500 RAM dump 0 occurrences
cmpi 0x800 (IPv4 EtherType), 19 sites in ROM none execute in either trace (icmpw.tr, rttcp2.tr)

So the Ethernet demux does not compare the EtherType with a literal cmpi. ARP input 0x3F6148E0 is therefore reached by some other classification — a table lookup or a masked compare — and that classification (the receive-side EtherType demux) remains the open item in this area. Practical note for the next attempt: the ARP input is only exercised when a peer has to be resolved, so a trace that starts with an empty ARP cache (fresh nvram) and pings the terminal from a new source address is the cheapest way to catch it.

27. The one claim that needs real silicon, and how to test it

Everything in this report is reachable from the ROM image and from MAME traces, with two exceptions — both about the same claim:

  • the transmit kick: the firmware writes CSR0 = shadow | TDMD, never a bare TDMD (section 10). MAME reproduces this: the loopback payload returns ok=1, rmd1=0x0300, mcnt=0x1C.
  • the same payload on real silicon completes INIT → IDON → STRT (CSR0 = 0x1B3, then a stable 0x33) but with the bare-TDMD kick the transmitter never services the demand: TDMD stays set and the TX descriptor stays owned (TMD1 = 0x8338). The corrected payload has not been run on the machine yet.

Status: deferred — the VT1200 is not available (taken off site), so this is not waiting on a power cycle any more, it is waiting on the hardware being back.

When it is, this is the whole procedure:

# 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):

/*
 *   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():

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

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:

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):

#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():

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:

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:

#define LE_XMD2_ONES 0xf000

and sys/dev/ic/am7990.c writes every descriptor length as

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:

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:

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:

git log --diff-filter=A --format='%ad %h' --date=short -- sys/dev/isa/if_le.c
git show <commit>:sys/netinet/ip_icmp.c

52. CSRG tree (original-bsd) — the firmware's own lineage, and IDON settled

/home/arti/code/original-bsd is the CSRG source tree: history back to 1979-12-04 (3bsd version), last commit the final CSRG delta (4.4BSD-Lite2). 180 MB, so full-tree searches are instant — the right tree for provenance questions.

52.1 Same ARP code, one release older

sys/netinet/if_ether.c:409:
        "arp: ether address is broadcast for IP address %x!\n",

identical to the string in the ROM and to the 386BSD import, and unlike modern NetBSD's "link address" wording.

52.2 LANCE drivers present in 4.4BSD

sys/hp300/dev/if_le.c        sys/luna68k/dev/if_le.c
sys/pmax/dev/if_le.c         sys/sparc/sbus/if_le.c
sys/news3400/if/if_lance.c

The pmax (DECstation) driver is the closest sibling to a DEC-built terminal stack, and its init sequence is (sys/pmax/dev/if_le.c:405-419):

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
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:

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:

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:

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:

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:

: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:

: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 —

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:

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:

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

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.