cmd 8 alternates 0xFFFF/0x0000 per frame so the rate is visible: 300 frames in 9.775 s on the unit, 32.6 ms each, 30.69 fps sustained. cmd 9 is the same byte-store loop aimed at a DRAM buffer, to measure the CPU's store rate without the VRAM arbiter and size a frame-expansion stage. vtcmd.py --dram selects it. Report 74.3 records the independent Ghidra pass over the ROM: the firmware sets CONVSP/CONVDP=0x14 (pitch 0x800 bits = 256 B), PSIZE=1, PMASK=0 and OFFSET=0x04000000 for XY addressing -- the same values vram.asm uses -- and fills with FILL_L chunked at 0x7FC0 pixels because DYDX is 15 bits per axis. There is no VBLT and no wait-state/arbitration register in the ROM, so the 12 us/byte CPU store cost is inherent to this board, not a setting.
216 KiB
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:
TDMDstays 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_scripttraces (trace <file>), e.g./tmp/opencode/ping_trace.lua,/tmp/opencode/win_trace.lua. - Headless debugger note: breakpoint
printfactions print nothing; use thetrace <file>action instead.bpsetaddresses 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 3to a byte address before it goes in the descriptor; - the length is written with
setf 12(a 12-bit field), value0xFDDAfor a 550-byte buffer; - OWN is written as a single byte
0x80at 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
0x0008alone clearsSTRT") is wrong. The AM79C90 model makesINIT/STRT/STOP/TDMDwrite-1-to-set andINEAread/write, so a bareTDMDdisables interrupts but does not stop the transmitter. The firmware's shadow exists to preserveINEA; and the real-siliconCSR0 = 0x1BBdecode (STRT=1,TDMD=1,TXON=1) shows a poll that never ran, which is a different problem entirely.
The transmit demand is sent as CSR0-read-back | TDMD. 0x23CC50 is not
a shadow cell — it holds the driver's RDP pointer (0x05800000), and the kick
reads CSR0 back through it:
3F9D5960 move @0x23CC50,a3,1 ; a3 = RDP pointer (0x05800000)
3F9D5990 move *a3,a5,0 ; a5 = *RDP = current CSR0
3F9D59B0 or a3=8,a5 ; a5 |= TDMD
3F9D59D0 move @0x23CC30,a5,1 ; a5 = RDP pointer (second alias)
3F9D5A00 move *a13,*a5,0 ; *RDP = a5
So the driver reads the live CSR0 and ORs TDMD, preserving whatever
read/write bits (notably INEA) are currently set. Section 10 called that cell a
"shadow" and section 62 records why that is wrong and what it broke.
11. The live driver paths (RX poll, TX completion)
The stack is polled: the driver's task calls a receive poll and a transmit completion check. Ring positions and counters are global cells:
| cell | meaning |
|---|---|
0x23E6A0 |
free receive buffers |
0x23EBA0 |
RX ring consumer position (poll reads here) |
0x23EBB0 |
RX ring producer position (refill writes here, & 0x7F) |
0x23EC50 |
list of receive buffers (data at entry + 0xA0 bits) |
0x23CB00, 0x23CB30 |
saved a0/a2 across a poll with no frame |
0x23CB70, 0x23CB90, 0x23CBB0, 0x23CBC0 |
TX/RX statistics |
RX poll — 0x3F9D62B0, body at 0x3F9D6300
a7 = *0x23EBA0 << 6 ; consumer position * 64 bits
a10 = *0x23E7E0 + a7 ; RX ring base (BIT address 0x5400)
setf 8
a6 = *(a10 + 0x18) ; descriptor byte 3 = RMD1 high byte
btst 7, a6 ; OWN still set -> no frame
jrne 0x3F9D8980 ; (saves a0/a2 to 0x23CB00/0x23CB30, returns 0)
a9 = *a10 ; buffer BYTE address (32-bit store)
jreq 0x3F9D8590 ; null buffer
a9 = (a9 << 8) >> 5 ; BYTE address -> BIT address
a9 -= 0xA0 ; back to the buffer-list entry
... link the buffer into the queue, *0x23E6A0 = *0x23E6A0 - 1 ...
btst 1, a6 ; STP
btst 0, a6 ; ENP
The poll returns 0 when the descriptor is still owned (the normal idle case) — this is the loop that replaces interrupts in the live stack.
TX completion — 0x3F9D8AF0
a5 = *0x23E820 ; TX ring base (BIT address 0x7400)
a11 = position * 64 + a5
setf 8
a9 = *(a11 + 0x18) ; TMD1 high byte
btst 7, a9 ; OWN still set -> not complete
jrne 0x3F9DAEF0 ; (saves a2/a4/a6 to 0x23CB90/0x23CBC0/0x23CBB0)
a12 = *a11 ; buffer BYTE address
jreq 0x3F9DAC80
a12 = (a12 << 8) >> 5 ; BYTE -> BIT
btst 1, a9 ; STP
btst 0, a9 ; ENP
... error/status bits update the statistics cells ...
How the poll is driven
The RX poll 0x3F9D62B0 and the TX routine are called from the interrupt
handler (0x3F9D5B40) on status bits 10 and 9 — 402 poll calls were counted in
an idle-window trace, driven by the broadcast/ARP traffic the terminal receives
even with no pings. So the hardware model for a payload is: ring + INEA, and
service CSR0 in the handler (or poll CSR0 yourself with INEA clear).
12. Handler dispatch and the observed protocol chain
0x3F9D5B40 is a pure status dispatcher. After reading CSR0 and clearing INTR
it tests the status word bit by bit (all evidence: the disassembly of
0x3F9D5B40..0x3F9D6280):
| status bit | action |
|---|---|
| 15 ERR | error counter 0x23ED20, call 0x3F9DAFF0(5) |
| 14 | counter 0x23EEF0 |
| 13 | counter 0x23EF50 (skipped while 0x23ECA0 bit 2 is set) |
| 12 | counter 0x23EE80 |
| 11 MISS | counter 0x23ED20 |
| 10 RINT | calla 0x3F9D62B0 — the RX poll |
| 9 TINT | calla 0x3F9D8A50 — TX completion |
| 8 | 0x23EC90 = 1 |
So the interrupt handler never builds or sends a frame itself: it only reaps
status and calls the RX poll / TX completion. Frame submission is done by the
protocol layer calling into the driver, which is why the init function
0x3F9D4BC0 (which contains the only TDMD write, at 0x3F9D57F0) is the
driver's command entry — it is called with (cmd, arg) from the protocol side
(and from the refill code at 0x3F9D3A30).
Protocol entry points seen in a ping window
Trace window frames 3000-4200 with an ICMP flood; the RX poll's callees, in order (addresses are bit addresses):
3F9DF160 / 3F9DF4C0 driver/task helpers
3F604780 3F606210 3F600200 3F60BD40 packet processing
3F9E0B80 3F9DEDA0 receive-queue / socket delivery
3FB2C000 3FB2C290 byte-swap (htons/htonl) + per-fragment checksum loop
3F61E490 3F61AB30 upper-layer (socket) handling
The TX completion routine 0x3F9D8A50 is entered 343 times in that window
(status bit 9), while the init-path kick 0x3F9D57F0 is never reached — so the
live TX is submitted from the protocol side into the driver command entry and
completed via 0x3F9D8A50.
Open item for the next pass: pin the exact protocol->driver send call (the
argument layout of 0x3F9D4BC0 and which command value means "transmit"), and
map the per-fragment checksum loop 0x3FB2C290 (chain at struct+0x40).
13. Protocol -> driver transmit submission (resolved)
The driver command entry 0x3F9D4BC0 is what the protocol layer calls to hand
over a frame. Evidence: in a ping-flood window (frames 3000-4200) the trace
contains 343 entries to 0x3F9D4BC0, all from the same call site:
3F9DCF00: MOVE A14,-*SP,1
3F9DCF10: MOVE A2,*A14+,1 ; last pushed -> arg2 = request/command value
3F9DCF20: MOVK 1,A5
3F9DCF30: MOVE A5,*A14+,1 ; arg1 = 1 (unit / path selector)
3F9DCF40: MOVE A11,*A14+,1 ; first pushed -> arg0 = device struct
3F9DCF50: CALLA 3F9D4BC0h
The wrapper just above (0x3F9DCE40) prepares the request at a11 + 0x100:
a7 = *0x23CCA0 -> *(a4+0x30) ; MAC / PADR copied into the request
a7 = *0x23CCC0 -> *(a4+0x50)
a10 -> *(a4+0x60)
so the request block is device+0x100 with fields at +0x30, +0x50, +0x60.
The callee's transmit branch
0x3F9D4BC0 receives (arg0 = device, arg1 = 1, arg2 = request):
a9 = arg0 ; device/ring structure
a10 = arg1 ; 1
if (a10 > 1) { print 0x3F9E1F78; ... } ; only "1" is a valid path
...
loop over the buffer chain:
a2 = *(a9 + 0x40) ; next buffer
a7 = *0x23EBF0 ; TX producer position
a7 <<= 6 ; 8-byte descriptor stride
a0 = *0x23E820 + a7 ; TX ring (BIT address 0x7400 = byte 0xE80)
a9 + 0x100 / +0x40 ... ; per-buffer fields
The loop arms the descriptors in the TX ring; the TDMD kick for that batch is
the code at 0x3F9D57F0 already documented in section 10:
a7 = *(a0 + 0x18) | 0x80 ; OWN the descriptor
a5 = *0x23CC50 ; a5 |= 0x0008 ; command shadow | TDMD
*0x23CC30 = a5 ; RDP = shadow | TDMD
So the transmit submission chain is:
RX poll 0x3F9D62B0
-> protocol (0x3F604780 / 0x3F606210 / 0x3F600200 / 0x3F60BD40 / 0x3F61E490 / 0x3F61AB30)
-> wrapper 0x3F9DCE40 (fills device+0x100 request: MAC at +0x30 ...)
-> driver command entry 0x3F9D4BC0(device, 1, request) [343 calls/window]
-> TX ring arming at 0x23E820 + producer 0x23EBF0
-> TDMD kick 0x3F9D57F0 = CSR0 shadow | 0x0008
-> TX completion later on TINT: handler -> 0x3F9D8A50 -> 0x3F9D8AF0
Open detail (recorded, not resolved): the exact byte layout of the
device+0x100 request block beyond +0x30, +0x50, +0x60, and the
packet-buffer objects behind 0x3FB2C000/0x3FB2C290.
14. The packet object and the +0x100 control block
Disassembly of the wrapper 0x3F9DC890 (the function that calls the driver
command entry at 0x3F9DCF50) shows that arg2 (a11) is a packet object,
not a device:
a9 = arg0
a10 = arg1
a11 = arg2 ; packet object
...
a7 = *(a11 + 0x90) ; packet state word
a5 = *(a11 + 0xF0) + 0x0E ; total length + 14 (Ethernet header)
a12 = a11 + 0x40
loop:
a7 = *(a12 + 0xB0) ; fragment length
a0 += a7 ; running total
a2++ ; fragment count
a12 = *a12 ; next fragment
Packet object fields (bit offsets from a11):
| offset | meaning |
|---|---|
+0x40 |
next fragment pointer |
+0x90 |
packet state (the wrapper checks it against 0x16) |
+0xB0 |
fragment length |
+0xF0 |
total packet length |
+0x100 |
control block built by the wrapper for the driver call |
The wrapper copies into the control block:
*(cb + 0x20) = *(a9 + 0x20)
*(cb + 0x30) = MAC from 0x23CCA0 (PADR)
*(cb + 0x50) = *0x23CCC0
*(cb + 0x60) = a10
Given the LANCE init-block field order (mode, PADR, LADRF, rings), a control
block whose PADR sits at +0x30 means the block starts 0x2E bits (mode) before
it — i.e. the wrapper is handing the driver an interface control block
(mode/PADR/LADRF/ring words) together with the packet object; the driver entry
with arg1 = 1 consumes it and, for the packet case, walks the fragment chain
and arms TX descriptors (section 13).
This is the last structural piece needed to describe the transmit path from the
protocol layer down to the ring and the TDMD kick; the byte-level semantics of
cb+0x50/cb+0x60 (the two ring words copied from 0x23CCC0 and a10) and
the 0x3FB2C000/0x3FB2C290 pool internals remain the documented open items.
15. Correction: 0x3FB2C000 / 0x3FB2C290 are utilities, not a pool
Disassembly:
0x3FB2C000— byte swap. The 16-bit form doessll 8 / andi 0xFF00 / srl 8 / or; a second entry does the same with0xFF00FF00/0x00FF00FFmasks for 32-bit values. That ishtons/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 ina9, a count ina10, rejects null/negative counts, reads*(a9+0x40)as the next link and iterates witha5 = 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 string0123456789abcdef, and the loop converts six bytes intoxx:xx:xx:xx:xx:xx— a MAC-address formatter. Thearpre…/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 helper0x3F6166D0, 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 helper0x3FB2C140.0x3F616090— converts two values (fromarg0+0x20andarg1+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;
>= 17returns immediately (0x3F661190is the function epiloguemmfm sp,a5..a13; rets 2); - each entry is two pointers:
fn0at+0andfn1at+0x20; - the two call sites seen in the trace use the two pointers of the same
entry:
0x3F6603A0callsentry[2].fn0=0x3F615E00(convert) and0x3F660BB0callsentry[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 for0x0B40|reg, 0x0008returned zero hits. That search was wrong: on the TMS34010 theCMPIimmediate is stored one's-complemented. A known-good site proves it —cmpi 0x8,a2 ; 0b42 fff7at0x3F9D2C80, i.e. the immediate word is~8, not8. Searching for0x0B40|reg, 0xFFF7finds 42cmpi #8sites. 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 byjrwith the 8-bit field size already set, and the earlier "noCMPI #8" finding was an encoding error in my search (CMPIimmediate is one's-complemented); - the echo request is answered by
movb 0,*(icmp+0)in0x3F6212A0, 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 function0x3F622A40); - 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 | TDMDkick 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 bareTDMD(section 10). MAME reproduces this: the loopback payload returnsok=1, rmd1=0x0300, mcnt=0x1C. - the same payload on real silicon completes
INIT → IDON → STRT(CSR0 = 0x1B3, then a stable0x33) but with the bare-TDMD kick the transmitter never services the demand:TDMDstays 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 = 0x1BBisIDON|INIT|STRT|TDMD|TXON|RXON|INTRwithERRclear; there is no memory error. The anomaly isINITstill being set whileIDONis set, which points at the initialization sequence (the datasheet wantsSTOPset beforeINIT) 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:
0x3F600200only works with frame lengths (cmpi 0x5EE,0x226,0x44C,0x200,0x400) — it assembles the frame into a packet block, it does not classify it;0x3F602E00checks 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 < 3default; 0x3F63F200has 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
arpresolvefamily around0x3F6105E0and0x3F6120A0(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
0x3F602E00with 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 function0x3F600200returns a value that0x3F602E00tests at0x3F6031A0; 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)whereindexcomes from a 4-bit field at*a11, and the object itself is passed as an argument by the UDP/TCP input paths. Whethera11is 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:
STRTis set-only. No path in the write handler clears it when a write omits it. Writing a bareTDMD = 0x0008therefore leavesSTRT/TXONset, and the demand is serviced (TDMDis cleared by the poll).INEAis genuinely read/write. A bareTDMDwrite 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:
- the transmit ring words in the init block were not what the chip latched
(ring base/length encoding,
CSR1/CSR2written whileSTOPwas clear); - the chip latched its init block from the wrong address (the init-block pointer
only loads when
INITis accepted, which requiresSTOP); - the descriptor as written is not accepted as owned by the chip (byte order of
the
OWNbit / 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:
- The corrected CSR0 semantics of section 40 are confirmed empirically: in the
model a bare
TDMDworks exactly as well, becauseSTRTis set-only and the demand is serviced regardless. - MAME never distinguished the two payloads. The earlier statement "MAME
verifies the corrected
shadow|TDMDkick" 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 Withdrawn — see
section 42.1: the payload does set INIT without first setting STOP.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
- read
CSR0immediately after theINITwrite and after theIDONpoll, to see whetherINITclears (0x1BBshows it did not); - set
STOP(0x0004) before writingINIT, per the datasheet, thenINIT, then pollIDON, thenSTRT, thenTDMD; - if
TDMDstill sticks, readCSR1/CSR2back 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:
INITwrite accepted but the chip keptINITset because the init-block DMA read did not complete as expected (the model performs it synchronously; a real chip reports errors throughMERR, which is clear here, so this is weaker);- the chip re-asserting
INITbecause the payload writesINITagain later (there is a retry path atlance.c:263), and the real chip not clearing it; - a real-chip
CSR0read-back quirk that leavesINITvisible 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.tracross 44 segments and 12 datagrams (section 39.1, re-measured after this correction); - the same function is guarded by a debug flag: at
0x3F61ECB0it reads@0x1E0BF0and, when set, logs through0x3F623B80before doing anything else (the same gate appears in0x3F622F10and0x3F623740).
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:
INITis 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 withINITset. That hypothesis is withdrawn.STRT,TXON,RXON,IDON,INTRagree exactly between model and machine: the chip was started and did complete initialization.- The real differences are the activity bits: on the machine
TINTandRINTnever fired andTDMDwas 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
INITandIDONset — 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)—CSR0after the waitcell(0x1E0)— how many of the 600 polls still sawTDMDset
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/TXONreadable as set — the bit was accepted;IDONstill pending because nobody acknowledged it;- consequently the internal sequencer never enters normal operation, so the
transmit poll is never scheduled and
TDMDis 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
0x3F9D36C0writes the length withsetf 12and the value0xFDDA=0xF000 | 0x0DDA-> 550 bytes, top nibble ones (section 10); - the TX ring initialiser
0x3F9D2FF0writes0xF000as 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.
61. CONFIRMED ON REAL SILICON: the corrected payload works
harness/udpstream/build/lance_fixlen.coff was loaded on the VT1200 (via
dwt/loader/hostload.py over rdebug) and its panel read off the screen. The
panel encoding is bits16(): 16 blocks, left to right = bit 0..15, white = 1.
| panel row | y | white blocks | decoded | meaning |
|---|---|---|---|---|
| 1 | 670 | 1,2,5,6,8,9,10,11 | 0x7B3 |
CSR0 after the TDMD kick |
| 2 | 700 | same | 0x7B3 |
CSR0 sampled later |
| 3 | 730 | 9,10,16 | 0x8300 |
TMD1 (OWN still set at the sample) |
| 4 | 760 | 9,10 | 0x0300 |
RMD1: OWN clear, STP|ENP |
| 5 | 790 | 3,4,5 | 0x001C |
MCNT = 28 bytes |
| 6 | 850 | (none) | — | TX-done indicator dark (TMD1.OWN still set) |
| 7 | 850 | 1st | ok = 1 | payload bytes intact at the RX buffer +12 |
The five boxes down the left edge are dot(0..4), the payload's progress marks;
all five lit means it reached the RX-completion call.
61.1 The result equals the MAME reference
MAME lance.coff: csr0=0x7B3 ok=1 rmd1=0x0300 mcnt=0x1C txmd1=0x8300
real lance_fixlen: csr0=0x7B3 ok=1 rmd1=0x0300 mcnt=0x1C txmd1=0x8300
ok is computed only if the received buffer still holds the transmitted pattern
(0x88 0xB5 0xDE at offset 12), so this is a complete loopback through the
chip on the machine, not merely a descriptor status.
61.2 What that settles
- The firmware's hardware-driving sequence is confirmed on real silicon: init
block, CSR1/CSR2/CSR3,
INIT,IDON,STRT, ring arming, descriptor format,TDMD— all of it behaves as the model and the disassembly said. - The earlier on-machine failure (
CSR0 = 0x1BB,TMD1 = 0x8338, no receive) was the payload's descriptor-length encoding, exactly as section 50 diagnosed: the field must be the two's complement (-len, i.e.0xFFE8/0xFA0A), and MAME hid the bug by ORing0xF000inget_buf_length(). TMD1 = 0x8300(OWN set) in both MAME and on the machine is a sampling artefact of this payload, not a failure: transmit completion is proven byRMD1(OWN clear),MCNT = 28andok = 1.
61.3 Operational note
The payload re-initialises the LANCE, so the terminal's network stack dies while
it runs — the rdebug connection used to load it is lost, and the results are only
observable on the panel (or in memory if the machine is still up). The
lance_restore.c variant adds a "give the chip back to the OS" block (re-program
CSR1/CSR2 from 0x23E7A0, INIT, poll IDON, STRT|INEA), but on this run it
did not bring the network back; the unit needed a power cycle. Fixing that is
open work, not a blocker for the result above.
61.4 The panel is now text, and the restore is instrumented
Reading 16 white boxes off a photograph of the screen is error-prone, so the payload now draws labelled hex text instead:
CSR0 7B3 TMD1 8300 MCNT 001C REST 0042
CSR1 7B3 RMD1 0300 OK YES
A 5x7 glyph table (0-9, A-F, and the letters the labels need) is built at
runtime — this toolchain never runs .cinit, so a statically initialised table
would arrive as zeros, the same reason harness/c-hello builds its font in code.
Glyph pixels are 8 px wide (the VRAM write granularity) and 3 px tall, on a
32-pixel row pitch.
REST is new: the CSR0 read back after handing the chip to the OS, so a
failed restore is visible rather than inferred.
Note on verification: the panel coordinates and the 2048-byte VRAM stride come from the real unit (they are the ones that displayed correctly there). MAME's frame dump does not reproduce the panel — the OS repaints the screen continuously there, and the real unit's VRAM layout differs slightly — so the panel is verified on the machine, not from an emulator screenshot.
62. Correction: 0x23CC50 is the RDP pointer, not a CSR0 shadow
Section 10 (and the restore code written from it) treated cell 0x23CC50 as a
software shadow of CSR0 and wrote 0x0042 into it. That is wrong and it
crashed the terminal with an illegal opcode on the machine.
The disassembly is unambiguous:
3F9D5960 move @0x23CC50,a3,1 ; a3 = value of the cell = 0x05800000
3F9D5990 move *a3,a5,0 ; a5 = *(0x05800000) = *RDP = current CSR0
3F9D59B0 or a3=8,a5 ; a5 |= TDMD
3F9D59D0 move @0x23CC30,a5,1 ; a5 = second RDP alias (also 0x05800000)
3F9D5A00 move *a13,*a5,0 ; *RDP = a5
0x23CC50 holds a pointer (0x05800000, matching the RAM dump in section 2),
and the driver reads the live CSR0 through it before OR-ing TDMD. There is no
shadow: the "preserved" bits are simply whatever the chip currently reports.
Consequences, all now fixed:
- writing
0x0042into0x23CC50overwrites the RDP pointer, so the driver's next CSR access targets address0x0042— the fault observed on the unit; - the restore must write CSR0 through
RAP/RDP, never through that cell; - section 10's phrase "CSR0 = shadow | TDMD" should read "CSR0 = read-back | TDMD".
The restore path in harness/udpstream/lance.c now re-programs the chip with
0x7F41 (the firmware's own init value: INIT | INEA | all status bits), polls
IDON, writes 0x0042, and leaves the driver's cells untouched.
63. Crash cause: the marker cells ran into .text
Adding the text panel grew .text from 0x018C6140 to 0x018CACD0, which
swallows the marker block at 0x018C8000. The payload then wrote
cell(0x160, …) and friends straight into its own code, and the CPU executed
the overwritten bytes — an illegal opcode before any text was drawn (four
progress dots, blank panel).
That is a general hazard of this payload layout: .text grows upward from
0x018C0000 while the markers were hard-coded at 0x018C8000. They are now at
0x018D0000, clear of the end of .text by ~2.6 KB.
Any future growth should re-check build/lance.map against MARK.
64. Text panel on the unit: loopback reproduced, chip hand-back works, OS does not
Photo: images/20261030_155855.JPG. Panel as drawn by
lance_fixlen.coff (text build, MARK moved to 0x018D0000):
CSR0 07B3 TMD1 8300 MCNT 001C REST 0073
CSR1 07B3 RMD1 0300 OK YES
and five progress dots down the left edge (the payload ran to the RX completion).
64.1 The loopback is reproduced
CSR0 = CSR1 = 0x07B3, TMD1 = 0x8300, RMD1 = 0x0300 (OWN clear, STP|ENP),
MCNT = 0x001C (28 B = 24 + FCS), OK = YES. Identical to the earlier
white-box run (section 61) and to the MAME reference — the text build changes
nothing in the test itself.
64.2 REST = 0x0073 — the chip was handed back correctly
0x0073 = INIT | STRT | TXON | RXON | INEA
(STOP clear, TDMD clear, INTR clear, IDON clear)
So after the test the payload re-programmed the chip from the firmware's own
init block (0x7F41), polled IDON, wrote 0x0042, and the chip responded:
transmitter on, receiver on, interrupts enabled. The INIT bit still being set
is normal — MAME's own 0x7B3 has it too (section 47.2).
In other words the "give the chip back" step works at the chip level. It has never been the chip.
64.3 What actually stays broken: the OS's Ethernet driver
The screen behind the panel shows the Terminal Manager with
Ethernet Port Error - 24 at the bottom, and the network is unreachable
afterwards (ping fails, rdebug port 771 closed).
So the recovery failure is in the firmware's own network stack, not in the LANCE: the driver noticed the chip disappear during the takeover, marked the port failed, and does not re-initialise on its own even when the chip comes back enabled.
Practical consequence, recorded so it is not re-litigated: a LANCE takeover run on this machine costs a power cycle. The restore code stays (it is correct and harmless), but the unit will not return to service by itself.
64.4 Where that leaves the hardware question
It is answered:
| question | result |
|---|---|
| does the firmware's init/ring/descriptor/TDMD sequence work on real silicon | yes (sections 61, 64.1) |
| was the old failure our payload or the chip | our payload: descriptor length must be -len (0xFFE8/0xFA0A) |
| can the chip be handed back to the OS from a payload | yes at the chip level (REST = 0x73), not at the driver level |
64.5 The stray white box in the photo
The photo shows one white box half over the REST row. That is the old ok
indicator (rect(120, 850, 24, 24, 0xFF)), left over from the white-box
layout: the text row REST sits at y 846-867 and the box at y 850-874, so they
overlap. The text panel already reports OK YES/NO, so both indicator boxes
(ok at x 120 and TX-done at x 160) have been removed. Cosmetic only — the
values in 64.1/64.2 are unaffected.
65. Correction: "Ethernet Port Error - 24" is a clean-boot condition
Section 64.3 used the banner Ethernet Port Error - 24 on the screen behind the
panel as evidence that the firmware's network driver had been left broken by the
payload. That is wrong: the terminal shows this banner after a clean boot
as well, while its Ethernet port works normally (the unit answers ping and the
rdebug service on port 771).
So the banner is the firmware's own Ethernet self-test result, not a runtime
fault. It is worth noting for the LANCE work that this matches the emulator: the
firmware's C - LANCE TEST also fails in MAME (LANCE_LOOPBACK_REPORT.md, the
RMD1_FRAM mismatch), while ordinary traffic passes. In other words the
self-test's expectation is not met by this hardware either — pre-existing, and
unrelated to the takeover payload.
65.1 What that changes
- The banner is not evidence about the payload. Section 64.3's conclusion ("the driver noticed the chip disappear and marked the port failed") is withdrawn.
- The claim "a takeover run costs a power cycle" therefore rests only on the
runs that crashed (the
0x23CC50write and the marker/.textoverlap). For the current build — the one that producedREST = 0x0073— the unit was never pinged after the run, so end-to-end recovery is unverified, not disproved. - The check is one command, after a successful (non-crashing) run:
ping -c3 -W2 100.103.100.103
66. The payload sends and receives on the wire — with its own driver code
A standalone payload (harness/udpstream/lance_bcast.c) now drives the LANCE
with no ROM calls at all: it writes its own init block (PADR, LADRF,
rings), programs CSR1/CSR2/CSR3, runs INIT/IDON/STRT, owns eight TX and
eight RX descriptors, kicks TDMD, and reads completions itself.
66.1 The test
harness/udpstream/test-bcast.sh runs it in MAME and reports the result:
== results ==
TXD (frames transmitted) = 6
RXF (frames received) = 5
last ethertype = 0x88B5
last source MAC hi word = 0xC6FC
ok = 1
payload broadcasts seen on tap-mess-1000-0: 55
== PASS ==
and the tap capture separates the two directions:
6 08:00:2b:16:4b:5a > ff:ff:ff:ff:ff:ff, ethertype 0x88b5, length 60 <- our payload
49 c6:fc:cc:16:f5:66 > ff:ff:ff:ff:ff:ff, ethertype 0x88b5, length 60 <- the host
TXD = 6 is the payload's own counter for descriptors it armed and saw
complete; RXF = 5 counts descriptors the chip filled with host frames (the
listen loop stops early at five), and the recorded source MAC 0xC6FC… is the
host's, not ours — so it really consumed frames from the wire rather than its own
transmissions.
66.2 What it took (all of these were my bugs, not the chip's)
| symptom | cause |
|---|---|
TMD3 = LCAR, no carrier |
MAME's -networkprovider taptun only permits the module; the interface choice lives in harness/mame/cfg-net/vt1200.cfg. An empty cfg dir means no tap is ever opened |
TXD = 1 then timeouts |
the payload always armed descriptor 0; the chip advances its own ring position after each completion, so frames 2..n were kicked at descriptors it had already passed |
| test killed instantly | pkill -f mame0289/vt1200 matched the wrapper shell's own command line; pkill -x vt1200 is the safe form |
markers read as 0x55555555 |
two bit-vs-byte addressing mistakes: the payload called rb(buf + 12) with a byte address, and the script read cell()'s bit offsets as byte offsets |
| payload finished before the host frames | its listen window was too short and it returned, letting the OS reuse the marker memory; it now listens for 400M iterations, stops early at five frames, and holds after drawing the panel |
66.3 What this establishes
- The register, ring, descriptor and DMA behaviour documented in this report is sufficient to build a working Ethernet driver: our code transmits frames that appear on the wire with the terminal's MAC, and receives frames sent to it.
- No firmware routine is involved — the payload is loaded by the COFF loader and runs from RAM.
- Practical consequence: a payload can now speak any Ethernet protocol it wants (ARP, IP, a custom one) without the OS's stack, which is the foundation for a wire-based loader or a faster frame path than the OS's ~20 datagrams/s.
67. Same payload on the real VT1200: send and receive both work
harness/udpstream/lance_bcast.coff was loaded on the unit with
dwt/loader/hostload.py, while the host sent 0x88B5 broadcasts at it from its
own NIC. The panel reads (some glyphs were missing at the time, see 67.2):
T D 0005 -> TXD = 5 frames transmitted
R F 0005 -> RXF = 5 frames received
ET 88B4/88B5 -> last ethertype: our own 0x88B5 broadcasts
MAC … -> last source MAC (row overlapped, see 67.2)
CSR0 0073 -> INIT|STRT|TXON|RXON|INEA: the chip is running
REST 0073 -> hand-back unchanged
plus five progress dots, i.e. the payload ran to the end.
67.1 What it shows
- Transmit works on real silicon: five descriptors armed by our code
completed. (
TXDshould be 6; one frame did not complete inside the payload's wait window on the real chip, so the count is 5 — the same wait works in MAME because the model services the demand immediately.) - Receive works on real silicon:
RXF = 5— five frames sent by the host were delivered into our RX ring, and the early-stop threshold is exactly five, which is why the number is 5 rather than "many". The recorded ethertype is0x88B5, i.e. the frames the host was sending. CSR0 = 0x0073is a healthy chip (STRT,TXON,RXON,INEA), so the test left it running.
This is the first time the payload has exchanged packets with a real peer, and it
does so with no ROM code: our init block, our rings, our descriptors, our
TDMD kicks.
67.2 Two display bugs found by reading the real panel
| symptom | cause |
|---|---|
T D / R F instead of TXD / RXF |
the glyph table had no X — gi() mapped it to an empty slot, so the letter simply did not draw |
| MAC row a smear of overlapping text | the three 4-digit MAC groups were drawn at x=312/384/456, but each group is 192 px wide, so they were painted on top of each other |
Both are fixed: X added, and the MAC is now one word per row (MAC0/MAC1/
MAC2) with REST moved to y 878.
68. Ring overlap: the reply was discarded by the chip, not by the wire
lance_cmd.c (the command/benchmark payload) received commands correctly —
cmds incremented and laststat was 0 — but no reply ever reached the host.
The TX descriptor read back as TMD1 = 0x8000 (OWN set, STP and ENP clear)
although the code had written 0x8300.
The DMA log (VT1200_LANCE_DMA_LOG) showed the chip doing this on all eight
TX descriptors, round-robin, forever:
r 00309A 8000 FFFF pc=018C9B00 cyc=275084712
w 00309A 0000 FFFF pc=018C9B00 cyc=275084712
Read 0x8000, write back 0x0000 — that is transmit_poll()'s "owned but not
start of packet" path (am79c90.cpp:329), which clears ownership and advances
the ring without transmitting anything.
The cause was an address overlap, and it was arithmetic, not the chip:
RX ring: 0x3020 .. 0x309f (16 entries x 8 B)
TX ring: 0x3060 .. 0x309f
The RX ring had been grown from 8 entries to 16 (to survive bursts) but TXR
was left at 0x3060, so all eight TX descriptors lay inside the RX ring.
The chip's RX write-back set OWN in what the payload believed were TX
descriptors, so every reply was rejected as a continuation fragment.
Fixed by moving the TX ring to 0x30C0, plus a compile-time guard so the same
mistake cannot compile again:
typedef char rx_tx_rings_do_not_overlap[(RXR + NRX * 64 <= TXR) ? 1 : -1];
This also explains the earlier "reply buffer has the broadcast address as its source MAC": that was a second, independent bug (the reply copied the request's destination MAC into its own source field), fixed separately. A frame whose source is multicast is dropped by the tap, so it would have failed even with correct descriptors.
69. Packet handling cross-checked against the four drivers
Four implementations are available locally and were read line by line:
original-bsd/sys/pmax/dev/if_le.c (the firmware's ancestor, section 52),
NetBSD-src/sys/dev/ic/am7990.c, linux/drivers/net/ethernet/amd/7990.c, and
linux/drivers/net/ethernet/amd/7990.h.
4.4BSD if_le.c |
NetBSD am7990.c |
Linux 7990.c |
this payload | |
|---|---|---|---|---|
| RX buffer | LEMTU 1518 (if_lereg.h:13) |
LEBLEN 1536 (lancereg.h:116) |
PKT_BUFF_SIZE 1544 (7990.h:45) |
1526 |
| ring | LERBUF |
sc_nrbuf |
8 RX / 2 TX (7990.h:35-36) |
16 RX / 8 TX |
| RX length word | -LEMTU (:344) |
-LEBLEN | 0xf000 (:186) |
-RX_BUFF_SIZE | 0xf000 (:200) |
0xFA0A |
| complete frame | (rmd1 & (STP|ENP)) == (STP|ENP) (:630) |
same, "dropping chained buffer" (:247) |
(bits & POK) != POK (:303) |
now both bits |
| FCS | len - 14 - 4 (:691) |
rmd3 - 4 (:256) |
(mblength & 0xfff) - 4 (:322) |
now - 4 |
| re-arm | clear RMD3, then OWN (:661) |
OWN, rmd2 = -LEBLEN|ONES, rmd3 = 0 (:259) |
clear mblength, then OWN (:346) |
now all three |
| TX descriptor | OWN|STP|ENP, tmd2=-len, tmd3=0 (:485) |
same (:513) |
POK|OWN, (-len)|0xf000 (:583) |
same |
| TX kick | TDMD|INEA (:491) |
INEA|TDMD (:524) |
INEA|TDMD (:583) |
now INEA|TDMD |
| slot still owned | panic("lestart") (:471) |
"missing buffer", break (:482) |
stop queue | skip, count txfull |
69.1 Why the buffer must be ≥ 1518
All three size the RX buffer at ETHERMTU + 14 + 4: the chip writes the
FCS into the buffer and includes it in RMD3. The payload's counter showed
this before it was fixed — a 60-byte frame was counted as 64 bytes. The earlier
1526 was therefore already legal; a 1500-byte buffer would not have been, and
RMD1_ENP handling alone would have miscounted the tail of a chained frame as a
whole frame.
69.2 What changed in the payload because of this
- a frame must have STP and ENP on the same descriptor, or it is counted as
chained_droppedand re-armed without being parsed; - the byte counter subtracts the 4-byte FCS;
- re-arm restores RMD2 and clears RMD3, with ownership written last;
- the TX kick is
INEA|TDMD, notSTRT|TDMD— no reference driver re-writesSTRT, and 4.4BSD and NetBSD both write exactlyTDMD|INEA; - a TX slot that is still owned is skipped and counted in
txfull.
70. The command protocol: read and stats over the wire
The payload (ethertype 0x88B6, magic 'K' 'S') answers two commands, which is
what makes the machine debuggable while the OS is down:
| cmd | request | reply |
|---|---|---|
1 read |
32-bit big-endian bit address, 16-bit length (≤ 64) | status, then the bytes |
2 stats |
— | RXF, bytes, cmds, chained_dropped, txfull, csr0_err |
| other | — | status 0xE002 |
harness/udpstream/vtcmd.py drives it: stats, read --addr --len, and
bench. The first frame after takeover is routinely lost (the ring is armed
before the receive loop runs), so requests are retried three times.
Confirmed in MAME and on the unit: read 0x018D0000 --len 16 returned
01 00 00 00 55 55 …, the payload's own MARK block — arbitrary memory over
Ethernet, with no ROM code and no OS.
71. Receive throughput on the real VT1200
The unit and the host are joined by a 10 Mbit half-duplex hub (the VT1200 has coax Ethernet). That fact governs every number below: above the receiver's capacity the delivered rate does not plateau, it collapses, which is what a half-duplex collision domain does under overload.
bench sends 60-byte and 1514-byte frames at a paced or unpaced rate, then reads
the payload's counters. Background traffic is subtracted by taking a before
reading; the payload's own control frames are subtracted explicitly
(frames = ΔRXF − Δcmds).
| frame | offered | delivered | rate | csr0_err |
|---|---|---|---|---|
| 1514 B | 1717–1723 fps, unpaced | 99.9–100 % | 856–858 fps, 10.37–10.39 Mbit/s | 0x9000 |
| 60 B | 3001 frames @ 1500 fps | 99.8–99.9 % (5 runs) | 1498–1499 fps, 88 KiB/s | 0x9000 |
| 60 B | 6001 frames @ 2000 fps | 58 % | 1165 fps | 0x9000 |
| 60 B | 12001 frames @ 4000 fps | 0.3 % | 12 fps | 0x9000 |
Readings and their limits:
- Full-MTU reception is wire-limited, not code-limited. 856–858 fps of 1514-byte frames is 10.37–10.39 Mbit/s of frame bytes; with preamble, FCS and inter-frame gap that is at or slightly above the nominal 10 Mbit rate (theoretical maximum for this frame size is ~813 fps at exactly 10.000 Mbit/s, so the link clock is a few percent fast or the sender's pacing is). Nothing was dropped.
- Minimum-size frames sustain ~1500 fps with ~100 % delivery. Above that the ring overflows and the hub also collides, and the two are not separable from the outside.
csr0_err = 0x9000isERR|MISS: the chip did run out of descriptors, but the bit is sticky in this build (it ORs CSR0 on every pass), so it records "at some point", not "how many". It was clear before the 2000/4000 fps runs and set afterwards, which is consistent with those runs overrunning a 16-deep ring. A per-event counter is the next refinement; the sticky bit cannot attribute the misses to a particular run.- The payload's
stats-command handling stalls the receive loop for the length of one reply transmit, so a burst that coincides with a command can produce misses that a pure data burst would not.
71.1 What is proven and what is inferred
Proven: the payload receives every frame offered up to the wire limit at full
MTU; the chip reports no error bits at that rate; 1500 fps of 60-byte frames is
reproducible at 99.8–99.9 % over five runs; the reply path works (stats and
read both answer on the unit).
Inferred, not proven: that the collapse above ~2000 fps of 60-byte frames is
the half-duplex hub rather than the LANCE. ERR|MISS proves the ring overflowed
at 2000–4000 fps, which is sufficient to explain loss; it does not explain why
the delivered rate falls from 1165 fps to 12 fps as the offered rate rises —
that shape (rather than a plateau) is characteristic of collision collapse, but
the unit's side of the hub is not instrumented.
71.2 A 64-deep ring crashed the unit
Raising NRX to 64 (ring at 0x3020–0x321F, TX ring and buffer moved to
0x3240/0x3300) ran correctly in MAME but died on the unit with an illegal
opcode during the first 60-byte burst. The 64 buffers grow the RX buffer pool
from 24 KB (0x4000–0x9F78) to 97 KB (0x4000–0x1B78A) of low RAM, and the
firmware's OS is still resident while the payload holds the chip. The payload was
reverted to the proven 16-deep layout; the illegal opcode is not reproduced in
MAME, so the memory hypothesis is an inference, not a demonstrated cause.
72. peek / poke / jump / reboot, so a taken-over unit can be driven
The command byte selects the operation; addresses are bit addresses throughout, exactly like every pointer the TMS34010 uses.
| cmd | name | request | reply |
|---|---|---|---|
| 1 | read / peek |
[17..20] address, [21..22] length (≤ 64) |
status, then the bytes |
| 2 | stats |
— | RXF, bytes, cmds, chained_dropped, txfull, csr0_err |
| 3 | poke |
[17..20] address, [21..22] length (≤ 1024), [23..] bytes |
status, 2-byte count written |
| 4 | jump |
[17..20] address |
status, 4-byte target, then control is transferred |
| 5 | reboot |
— | status, 4-byte target, then control is transferred |
| — | — | — | 0xE002 unknown, 0xE001 read too long, 0xE003 poke truncated |
The reply is transmitted and its descriptor completion waited on before the transfer, so the host always gets an acknowledgement.
72.1 jump needs the entry point, not .text
The COFF entry is 0x018CBEB0; 0x018C0000 is only the start of .text.
Measured in MAME:
jump 0x018C0000→ payload stops answering and does not come back;jump 0x018CBEB0→ the payload restarts its owncrtand answers again with counters back to zero (RXF=1, i.e. just thestatsrequest itself).
72.2 reboot jumps to the ROM reset vector
reboot reads a 32-bit word from bit 0xFFFFFFE0 and jumps there. On this ROM
that word is 0x3F200040, the same entry the CPU takes at power-on. The first
instructions there are (harness/mame/reset.dasm):
3F200040: 0360 DINT
3F200050: 0550 SETF 10h,0,0
3F200060: 0740 SETF 20h,0,1
3F200070: 5600 CLR A0
3F200080: 0580 00D0 0640 MOVE A0,@64000D0h,0
...
DINT first, then the DUART control registers — so the entry does not depend on
the state a payload leaves behind, and at real power-on SP is undefined
anyway, which is the condition we reproduce.
MAME evidence: after the reply, the PC leaves the payload (0x018Cxxxx) for ROM
(0x3FB43E40 at the next sample) and the payload stops answering.
72.3 On the real machine it has NEVER worked (0 for 2)
Two attempts, both issued while the payload owned the LANCE, both acknowledged
(control -> bit 0x3f200040, status 0), both followed by a screen that lost
sync — the monitor went to sleep rather than showing the power-on circle.
| attempt 1 | attempt 2 | |
|---|---|---|
| time | 16:58:45 | 17:04:15 |
| payload state | RXF=11 |
RXF=15 (fresh power cycle before it) |
| the payload's own effect | none observed | none observed |
| recovery | manual power cycle by the operator | none; still down at 160 s |
| port 771 | reopened 88 s later (that was the power-cycle boot) | never |
| display | Terminal Manager after the manual cycle | monitor asleep |
Attempt 1 was initially recorded here as a success: port 771 reopened 88 s later, the unit pinged, and a VRAM screenshot showed the Terminal Manager — so the jump was credited with a working hand-back. The operator has since confirmed that the recovery was a manual power cycle, not the payload. 88 s is simply how long this machine takes to come up; our jump contributed nothing.
So the honest result is 0 confirmed successes out of 2, and the first reading of attempt 1 — "black and hung, needs a power cycle" — was right.
Three corrections are recorded here on purpose, because they are all the same mistake and it was made three times: "hung" from one sample, then "works" from the same sample once it had recovered, then "works" again when the recovery was attributed to the payload. The recovery had an external cause (the operator), which a single observation cannot distinguish from the payload working.
Hypothesis for the failure (untested). The reset path does not reset the
LANCE, and the payload hands over with the chip started and bus-master DMA
enabled. If a transfer is in flight — or an interrupt is pending — when control
jumps, the firmware's early board-window handshake (0xC00000B0 / 0xC0000100
in the trace above) can wait forever, which is exactly what is seen: dead
display, no network, no progress. The single-variable experiment would be
CSR0_STOP (and masking interrupts) immediately before the jump so the reset
path starts with a quiet chip. It has not been run.
vtcmd.py reboot requires --force, states that it has never worked, and says
a power cycle may be needed. jump and reboot still transmit their reply and
wait for the descriptor to complete before transferring control, so the host
always gets its acknowledgement first — which is why the failure is silent from
the host's side.
72.4 There is no vendor reboot to jump to (checked in Ghidra)
The diagnostics menu looked like the obvious place for a supported restart, so
the ROM was read in the Ghidra project (ghidra/vt1200.gpr, TMS34010). The
complete menu is at rom:0x07fb9532 (byte addresses in the image; the string
address is bit 0x3FDC…):
VERSION 2.08 - SELECTED TEST TYPE:
A[x]/Y - ROM TEST (x = bank select)/NVM C - LANCE TEST
D - KEYBOARD TEST E - DUART1, CHANNEL A (HOST)
F - DUART1, CHANNEL B (PRINT) G - DUART1 TIMER
H - DUART2, CHANNEL A (KEYBOARD) I - DUART2, CHANNEL B (MOUSE)
J - DUART2 TIMER K - MOUSE/TABLET TEST
L - VIDEO TEST M - ETHERNET ROM TEST
N - VIDEO MEMORY TEST O - LOOPBACK MODE (TOGGLE)
P[x] - PATTERN W[x] - CONTINUE FOREVER / LOOP ON ERROR
X - RESET THE PRESENT SELECTION Z - RUN THE TEST SELECTION
Q - SELECT ALL TESTS U/V - BUILD LNKTABLE/RUN BUS PATH CHECK
S[x] - SELECT FCC MODES T[x] - IMAGE / MENU / DISPLAY INT
- No reboot/restart entry.
Xresets the test selection, not the machine. - The string
REBOOTdoes not occur anywhere in the 1.75 MB image, and neither doesRESTARTas a menu action (the two hits are "Terminal Manager XIO error… restarting…" and "server restarted" — OS messages). - The reset entry
0x3F200040appears as a constant twice in the whole ROM, atrom:0x07e40c2eandrom:0x07e40c64, inside the early handler region, and both reduce to a compare against0x40(ANDI 0xffff/CMPI 0x40). Neither transfers control there.
So the firmware never reboots itself; a reset is the hardware reset line. The
reboot command's jump to the reset vector is therefore the only software
path, and the measured ~88 s is the cost of that path — POST runs in full, with
the screen blank while it reprograms the video window.
If a faster return than that is ever needed, the alternative is not a reboot but a hand-back of the chip while the OS keeps running (section 64): the display and the OS survive, the network does not.
73. The real reboot target: the OS cold-start entry, not the reset vector
Section 72.4 established that the firmware has no reboot routine and that
jumping at the reset vector wedges the machine (0 for 2). But the reset vector
is the wrong entry: it is the start of POST, which reprograms the board window
from a state a payload has already changed. The firmware's own boot has a second
entry, the OS cold start at 0x3F5788C0 — the address the boot gate at
0x3F2042D0 branches to when it skips the level-1 batch (vt1200.cpp:134).
That entry starts by fixing exactly what a payload breaks
(rom:0x07eaf118, bit 0x3F5788C0):
07eaf118 DINT
07eaf11a CLR A10
07eaf11c SETF 0x0,0x0,0x1
07eaf11e SETF 0x10,0x0,0x0
07eaf120 CLR A0
07eaf122 MOVE A0,@0x5900000 ; LANCE RAP = 0
07eaf128 MOVI 0x4,A0
07eaf12c MOVE A0,@0x5800000 ; CSR0 = 0x0004 = STOP <- quiet the chip
07eaf132 MOVI 0xC,A0
07eaf136 MOVE A0,@0xc00000b0 ; board window = 0x0C
07eaf140 MOVI 0x3f57aa70,A2 ; then re-program the board tables
07eaf146 MOVI 0xc0000080,A3
07eaf14c MOVE *A2+,*A3+,0x0 ; (3 words)
07eaf156 MOVI 0x3f57aaa0,A2 ; and the next table to 0xc0000130
DINT, CSR0=STOP, board window — in that order. This is the path that is
designed to run when the machine is handed over, and it is fast: no POST.
lance_cmd.c exposes it as command 6, osboot.
73.1 MAME: the OS re-initialises the chip
VT1200_LANCE_DMA_LOG/VT1200_LANCELOG shows the handoff and the vendor's own
re-init, with the payload's last access and the OS's first access 130 µs apart:
t=11.823527 W RDP val=0048 pc=018C0E40 <- last payload access
t=11.823657 R RDP val=02F3 pc=3F9D5BC0 <- OS LANCE handler
t=11.828933 W RDP val=0004 pc=3F578940 <- CSR0 = STOP, inside 0x3F5788C0
t=11.828960 W RDP val=0004 pc=3FDDCAE0 <- OS driver (io5800_5900_init)
and the OS keeps servicing the chip afterwards (pc=3F9D*, 134 accesses in the
window). So the cold start restores the LANCE and the OS continues.
73.2 Real unit: works, 40 s (1 of 1)
osboot issued at 17:11:21 while the payload owned the LANCE:
osboot: control -> bit 0x3f5788c0, status 0
rdebug back after 40s at 17:12:01
pings
A VRAM screenshot (dwt/screenshot.py --real) at that point shows the Terminal
Manager fully operating — 08-00-2B-16-4B-5A, 100.103.100.103,
LAT_08002B164B5A, 4 MB free, the clean-boot Ethernet Port Error - 24 banner
and the boot messages (Attempting to set font path. / Using local fonts.) in
the Messages pane.
Compare with reboot (section 72.3), same machine, same payload:
| command | entry | display | port 771 | ping |
|---|---|---|---|---|
reboot |
0x3F200040 (POST) |
never returns | never (0/2) | no |
osboot |
0x3F5788C0 (OS cold start) |
back, TM live | 40 s | yes |
Also observed: the display comes back before the network. At 32 s the
operator could see the screen while :771 was still closed; the interface comes
up later in the OS boot.
73.3 Second run: 2 of 2, also 40 s
Repeated at 17:14:10, and this time the machine started from a state
osboot itself had produced (no power cycle in between), rather than fresh from
one:
=== osboot #2 at 17:14:10 ===
osboot: control -> bit 0x3f5788c0, status 0
rdebug back after 40s at 17:14:50
pings
A second screenshot shows the Terminal Manager live again — 08-00-2B-16-4B-5A,
100.103.100.103, LAT_08002B164B5A, 4 MB free, and the boot-time
Attempting to set font path. / Using local fonts. messages.
73.3.1 Third run, scripted: 3 of 3, 40 s again
The automated hand-back (harness/udpstream/test-osboot.sh) was then run end to
end on the unit:
[*] listening for the announce on enp197s0f3u1
[*] loading udpstream/build/lance_cmd.coff onto 100.103.100.103
announce from 08:00:2b:16:4b:5a after 7.2 s
payload: RXF=1 bytes=60 cmds=1 ... txfull=0
[*] handing back with osboot
osboot: control -> bit 0x3f5788c0, status 0
[+] back after 40s
3 of 3, 40 s every time, and this run also measured the load: the payload is live 7.2 s after the script starts, so the hand-back costs 7 s of load plus 40 s of OS restart. The old test harness slept a fixed 45 s after starting the loader, which is why the operator watched a payload sit idle for ~38 s — the sleep, not the machine.
osboot is now the documented hand-back, and it is the only command in this
report whose success rate on the real machine is above zero.
73.4 The payload announces itself, so the host does not sleep
Loading the COFF over rdebug pokes thousands of words one at a time, so how long the payload takes to appear varies by tens of seconds; a fixed sleep is either wasteful or too short. The payload therefore transmits an announce the moment its RX ring is armed:
- three broadcast frames, ethertype
0x88B6,'K' 'S', opcode0xFF, magic"LANC"at[17..20]; - back-to-back, with no clock involved (three because this is a half-duplex segment and one lost broadcast should not cost a cycle);
- sent from TX slot 0, because the chip transmits only from its ring position and arming any other slot does nothing until the ring reaches it (a first attempt on slot 7 was silently never sent — measured);
- after the announce the reply cursor advances to slot 1, matching where the
chip's ring position now is, so replies never collide with the announce
descriptor; announce skips are not counted in
txfull, which stays the reply path's diagnostic.
vtcmd.py wait listens for it, and test-osboot.sh ties the whole hand-back
together with no sleep: listen, load, wait for the announce, stats,
osboot, wait for :771. Measured on the unit: announce at 7.2 s, :771 back
40 s after the hand-back (section 73.3.1).
74. VRAM writes: byte stores are 70x slower than the hardware FILL
The question "can we stream video into VRAM" reduces to how fast a frame can be
written. Measured with the payload (cmd 7), byte-at-a-time stores at the
display's own geometry (1280x1024, 256-byte stride, 160 bytes per row):
filled 20 frame(s) x 1024 rows: 3276800 stores in 40.040 s
2002.0 ms per frame -> 0.50 fps ceiling
81840 bytes/s (12.22 us per byte store)
12.2 us per byte is about a scanline period, i.e. the CPU gets almost nothing through the VRAM arbiter this way.
74.1 The manual's answer: FILL and PIXBLT
The TMS34010 has array instructions that run in the GSP's pixel engine, and the non-display path only pays for them once per array. From the User's Guide:
- FILL L — Table 13-6, long rows
(1+N*G)L + 2states; Table 13-7 givesG = 2for "replace, no plane masking". WithN = 80words/row andL = 1024rows that is 164,864 states, ~16 ms at a 10 MHz state. - PIXBLT B,L — Table 13-9,
[(2+G)N]L + 5= 327,680 states, ~33 ms.
Implied operands (instruction reference): DADDR=B2, DPTCH=B3, DYDX=B7
(XY form, (rows<<16)|columns), COLOR1=B9; plus CONTROL (0C00000B0h,
PPOP bits 10-14 = 0 for replace), PSIZE (0C0000150h = 1), PMASK
(0C0000160h = 0, i.e. nothing protected — a 1 would block a plane). Note the
manual's FILL page lists CONTROL at C0000090h; the register map (Figure 6-1)
says C00000B0h is CONTROL and C0000090h is DPYSTRT, and the reset trace
writes C00000B0h, so B0h is right.
harness/udpstream/vram.asm implements it: save B2/B3/B7/B9, load the four
implied operands, set PSIZE/PMASK/CONTROL (CONTROL is read-modify-write so
CD, RM and RR survive), read CONTROL back because I/O writes are
pipelined, then FILL L. The pattern comes from the C global _vram_pattern
so no calling-convention guess is needed.
74.2 Measured, both machines
| byte stores | FILL L |
speedup | |
|---|---|---|---|
| MAME | 1992 ms/frame | 27.1 ms (36.9 fps) | 74x |
| real VT1200 | 2284 ms/frame | 32.7 ms (30.6 fps) | 70x |
=== REAL FILL L, 30 frames ===
filled 30 frame(s) x 1024 rows (FILL L): 2457600 stores in 0.980 s
32.7 ms per frame -> 30.62 fps ceiling
2508224 words/s (0.40 us per destination word)
So a full-screen write costs ~33 ms, and the frame budget for video becomes:
- full frame over the wire: 163,840 B at the measured 1.27 MB/s = 8 fps — the network, not the CPU, is the limit for uncompressed full frames;
- small source expanded on the device (160x128 = 2,560 B/frame, 2 ms of
wire): the ~33 ms fill/transfer dominates, so ~30 fps full screen is the
target, provided the expansion itself is done with
PIXBLTrather than CPU stores.
cmd 8 (vtcmd.py vram --fill) is the benchmark; cmd 7 is the byte-store
control. Both report what the payload counted, not what it was asked to do.
74.3 The ROM does the same thing (Ghidra cross-check)
A static pass over the ROM found the firmware using exactly these instructions,
with values that match what vram.asm sets:
| ROM site | what it does | matches |
|---|---|---|
rom:07e40a72 |
MOVI 0xc0000000,A1 then copies 11 words into the CRTC block, clears CONTROL bits 10-14 (raster op = replace), then copies 5 words into 0xC0000130..0x180 = CONVSP, CONVDP, PSIZE, PMASK |
our CONTROL/PSIZE/PMASK setup |
rom:07e40b7c (profile table) |
CONVSP=0x14, CONVDP=0x14, PSIZE=0x1, PMASK=0x0; the 34010 decodes 0x14 as 1 << (~0x14 & 0x1f) = 0x800 bits |
our DPTCH = 0x800 (256-byte stride) |
rom:07ee9db6, rom:07f6628a |
MOVI 0x4000000,B4 — B4 is OFFSET, the XY-mode framebuffer base |
VRAM base 0x04000000 |
rom:07f68996, rom:07f689c0 |
FILL_L, chunked: one call for <= 0x7FC0 pixels, then MOVI 0x17fc0,B7 for the rest |
our single FILL L (1280x1024 fits one call) |
rom:07f688d8, rom:07f6890a |
rectangle copy = PIXBLT_L_L, same 0x7FC0 chunking |
the planned frame blit |
rom:07f66350, rom:07f66372 |
glyph/text blit = PIXBLT_B_XY (packed 1-bpp source, raster op 0x0B then 0x00) |
the planned expand path |
Two negative results worth keeping: there is no VBLT anywhere in the ROM
(so "video" is not a DMA engine), and there is no wait-state or CPU/VRAM
arbitration register — the 34010's I/O file is only 32 words and the firmware
touches none of them for this. The 12 µs/byte is therefore inherent to CPU pixel
stores on this board, not a misconfiguration we could tune.
The chunking matters for our own code: DYDX is 15 bits per axis, so a fill or
blit larger than 0x7FC0 pixels in a row must be split, exactly as the firmware
does.
74.4 A visible check: 300 alternating frames
Filling white alone proves nothing on a display. cmd 8 now alternates
0xFFFF/0x0000 per frame, and 300 frames were run on the unit:
filled 300 frame(s) x 1024 rows (FILL L): 24576000 stores in 9.775 s
32.6 ms per frame -> 30.69 fps ceiling
9.8 s of full-screen black/white flash at ~31 fps — the operator's own description was "damn that's fast". It also shows the number is sustained, not a one-frame burst.