TCP-SER-B0-D02 — how the whole thing is wired together¶
Image: TCP-SER-B0-D02.BIN
(524288 bytes, md5 f7a7ec0d365f27833c8494413681d5d2, merged from the four
TCP-SER-B*-D02.BPUN bank files, which were not modified)
Ghidra program: TCP-SER-B0-D02.BIN
Written: 2026-08-19
Companion file (full depth-4 trees, 1227 lines):
TCP-SER-D02-CALL-TREE-FULL.txt
0. How to read this document¶
Everything in here was measured off the bytes of the image, not taken from the ENCOS firmware and not taken from a manual. Where something is an inference rather than a measurement it says so in the line itself. There are three tags:
| Tag | Means |
|---|---|
| (measured) | read directly out of the image or the Ghidra database |
| (inferred) | a reading that fits the evidence but has not been pinned down |
| (unknown) | listed so it does not get quietly forgotten |
Method. The call graph was not built by looking at functions one at a time. It was built by sweeping the whole image for call instructions and then grouping the routines by who calls them. That is what made it possible to name 545 routines in a few passes instead of a few per pass.
- Find every routine entry by scanning for the PLANC-MC prologue
2F 0E 2C 56(move.l A6,-(SP)/movea.l (A6),A6). 545 routines. - Sweep every call instruction in the image:
4E B9/4E F9(jsr/jmp absolute long),4E BA/4E FA(jsr/jmp PC-relative) and61 xx(bsr, both the 8-bit and 16-bit displacement forms). Keep an edge only when the target lands exactly on a prologue. 1215 call edges. - Group the unnamed routines by exclusive ownership — a routine called only from inside one subsystem belongs to that subsystem.
The PC-relative forms matter. They were missing from the first sweep. Adding them only found 2 extra edges, so the conclusions did not move — but that was checked, not assumed.
1. Module map¶
The image is not laid out as tidy contiguous modules; the ranges below are where each subsystem's routines actually sit, and a few routines from one module fall inside another module's span. Counts are of PLANC routines only.
| Module | Address span | Routines | What it is |
|---|---|---|---|
PIOCOS |
0x001A12–0x0044C4 | — | the PIOC-OS kernel + PLANC runtime. Byte-identical to ENCOS |
ENMA |
0x004600–0x006A00 | 48 | Ethernet MAC driver, Am7990 LANCE, the interrupt handler |
POMN |
0x006A00–0x008000 | 17 | process/port bring-up, INITSUPERK, fatal-error reporting |
TCPD |
0x008000–0x0089C0 | 5 | the TCP console/daemon side, string+number printing |
AIP |
0x0089C0–0x00A700 | 24 | ARP, IP in and out, ICMP, fragment reassembly |
TCP |
0x00A700–0x00E900 | 35 | TCP protocol block, ioctl dispatch, PRU entry |
RAW |
0x00E900–0x00F500 | 3 | raw sockets, buffer alloc/free |
UDP |
0x00F500–0x010300 | 3 | UDP in/out and its PRU entry |
MAIN_SO |
0x010300–0x014000 | 44 | the main loop and the socket layer |
FSMR |
0x014000–0x019E00 | 74 | the TCP state machine, output, ack, reassembly |
TELNET |
0x019E00–0x01C300 | 35 | the telnet server |
SLib |
0x01C300–0x01E000 | 32 | the BSD-shaped socket API (SLsocket, SLbind, …) |
SLtrace |
0x01E000–0x021400 | 62 | the socket-library trace/debug layer |
XMSGsess |
0x021400–0x028100 | 93 | XMSG session handling towards SINTRAN |
XMSGlib |
0x028100–0x028900 | 19 | the XMPF* wrappers — one per XF* function code |
XMSGsup |
0x028900–0x02B900 | 45 | XMSG support, status mapping, error reporting |
PLANCrt |
0x02B900–end | 6 | ND_IMU, ND_IDV, ND_REMV, PLANC_MoveBlock, MON traps |
545 routines, 1215 call edges, 96 with no incoming call edge at all.
Every one of the 906 functions in the Ghidra database now carries a name — there
are zero FUN_ left. The 906 is larger than the 545 above because it also counts
the 200-odd two-byte fault stubs, the 21 one-instruction interrupt-ignore stubs,
and the hand-written kernel routines that do not use a PLANC prologue.
2. Who depends on whom¶
Edge labels are the number of distinct caller→callee routine pairs crossing that module boundary (measured). Only edges of 8 or more are drawn; the long tail is in section 3.
graph TD
ENMA[ENMA<br/>LANCE driver]
AIP[AIP<br/>ARP / IP / ICMP]
TCP[TCP<br/>protocol block]
FSMR[FSMR<br/>TCP state machine]
RAW[RAW]
UDP[UDP]
SO[MAIN_SO<br/>main loop + sockets]
TELNET[TELNET<br/>server]
SLib[SLib<br/>socket API]
SLtrace[SLtrace<br/>trace layer]
XSESS[XMSGsess]
XLIB[XMSGlib<br/>XMPF* wrappers]
XSUP[XMSGsup]
POMN[POMN<br/>bring-up]
TCPD[TCPD<br/>console]
TELNET -->|33| SLib
SLib -->|45| SLtrace
SLtrace -->|18| XSESS
XSESS -->|40| XLIB
XSESS -->|33| XSUP
XLIB -->|18| XSUP
POMN -->|26| XSUP
TCPD -->|19| XSESS
FSMR -->|44| TCP
TCP -->|30| FSMR
SO -->|25| TCP
FSMR -->|19| SO
TCP -->|18| SO
SO -->|15| XSESS
ENMA -->|12| XSUP
RAW -->|12| FSMR
AIP -->|11| FSMR
UDP -->|11| FSMR
TELNET -->|10| XSUP
SO -->|9| FSMR
FSMR -->|9| XSUP
RAW -->|8| SO
SLib -->|8| XSUP
XSESS -->|8| SLtrace
Three things this picture makes obvious that a per-function walk never would:
TCPandFSMRcall each other heavily in both directions (30 and 44). They are one protocol implementation split across two address ranges, not two layers stacked on each other.- Everything eventually lands in
XMSGsup. Eleven different modules call into it. It is the bottom of the stack — the way this card talks to SINTRAN. SLibbarely talks to the protocol code at all (6 edges toFSMR). It talks toSLtrace(45). The socket API is a thin shell over the trace layer, which is the thing that actually carries the request across to the host.
3. The dispatch tables — a correction¶
Six jump tables were found in the image. Only one of them is a table of routines. The other five point at labels inside a single routine — they are computed branches into the arms of one big function, so they add no call-graph edges at all. An earlier note of mine treated them as call tables; that was wrong, and the sweep returning zero targets for them is the correct answer, not a bug in the sweep.
| Table | Address | Slots | Targets are… |
|---|---|---|---|
tcp_action_matrix |
0x06A2D8 | 26 | real routines — all 26 land on a PLANC prologue, but only 25 distinct: slots 0 and 24 are both FSMR_NoTransition (measured) |
tcp_pru_dispatch |
0x075490 | 25 | arms inside TCP_UsrReq (0x0D510); targets 0x0D5EC–0x0E2BA (measured) |
ip_protocol_demux |
0x03701C | 18 | arms inside IP_Input (0x09C1E); targets 0x0A106–0x0A23C (measured) |
icmp_type_table |
0x036FDA | 16 | arms inside IP_Input; targets 0x0A168–0x0A22A (measured) |
socket_request_table |
0x075ED4 | 25 | arms inside the routine at 0x013478 (measured) |
ip_demux_2 |
0x037066 | 40 | arms inside IP_DeliverQueued (0x0A258); targets 0x0A2BE-0x0A364 (measured) |
So TCP_UsrReq does dispatch on the request code — through this table, into
its own arms. The absence of a cmpi.w chain proved nothing.
And the IP protocol demux is real: 18 slots indexed by IP protocol number, of which exactly three differ from the default "unknown protocol" arm at 0x0A23C:
| Slot | Protocol | Arm |
|---|---|---|
| 1 | ICMP | 0x0A132 |
| 6 | TCP | 0x0A106 |
| 17 | UDP | 0x0A11C |
| all others | — | 0x0A23C (drop) |
3a. The protocol switch — how the "uncalled" routines are reached¶
96 routines have no incoming call edge. 31 of them are explained by a table entry (measured — every root address was searched for as a 32-bit constant anywhere in the image):
- 26 are the TCP action routines, reached only through
tcp_action_matrix. RAWusrequest,UDP_UsrReqandTCP_UsrReqare reached only through astruct protoswarray — three entries of 44 (0x2C) bytes at 0x07536A.
Field offsets measured by lining the three entries up against each other:
| base | family +0x00 | protocol +0x04 | pr_input +0x08 | pr_output +0x0C | pr_usrreq +0x18 |
|---|---|---|---|---|---|
| 0x07536A raw | 2 | 0x0040 | RAWinput |
— | RAWusrequest |
| 0x075396 UDP | 2 | 17 | UDP_Input |
MAIN_UdpOutput |
UDP_UsrReq |
| 0x0753C2 TCP | 2 | 6 | TCP_Input |
— | TCP_UsrReq |
Family 2 is AF_INET in all three. TCP having no pr_output is correct, not a
gap — 4.2/4.3BSD leaves it zero too, because TCP transmission is driven by the
state machine (FSMR_TcpOutput) and never called through the switch.
The protocol numbers 6 and 17 here match the live slots in both IP demux tables. Two independent structures agreeing on which protocols exist is a real cross-check, not a restatement.
The remaining 65 have no reference of any kind — not a call, not a table, not
a vector. Some are genuine process entry points planted at runtime in a PIOC-OS
object descriptor (TELNET_Main @0x1C10E is one). The rest look like library
code linked in whole and never used (inferred — not each one checked).
The lesson worth keeping: a routine with no xrefs is not dead code. Look for its address in a table first.
3b. The self-naming strings, and a name table nothing uses¶
This image has no vendor symbol table, so routines were named partly from
"MODULE.Routine" strings the code loads into its own frame. Four were
re-confirmed this pass (measured — in each case the lea site sits inside the
routine the string names):
| String | at | loaded from | routine |
|---|---|---|---|
MAIN.MainLoop |
0x0753F2 | 0x01049A | MAIN_MainLoop 0x103E6 |
MAIN.UdpOutput |
0x0753FF | 0x00F9B4 | MAIN_UdpOutput 0x0F998 |
FSMR.TcpExtractOob |
0x075F84 | 0x018814 | FSMR_TcpExtractOob 0x1874A |
FSMR.SendPacket |
0x075F96 | 0x01655C | FSMR_SendPacket 0x164D4 |
FSMR.TcpTemplate |
0x075F74 | nothing | cannot be mapped |
FSMR.TcpTemplate sits between two strings that are used, but no code
anywhere loads it. Not resolvable from this image — the routine was presumably
inlined or dropped. Closed, not pending.
A 27-name table for a layer that is not here¶
At 0x075FE4 there are 27 PLANC array descriptors (12 bytes each,
{origo, lower, upper}, lower always 0 so length = upper+1) pointing at strings
from 0x076128:
0 SLinit 7 SLsocket 14 MtRqCnct 21 FindFreeMailbox
1 SLbind 8 SLfork 15 MtRqDcnct 22 "NetFlush negative"
2 SLaccept 9 SLioctl 16 MtRead 23 "MtRead plus"
3 MtResMb 10 SLsense 17 MtWrite 24 mtRespCnct
4 MainTask 11 SLrecv 18 MtSensI 25 ConnectToSintran
5 Initialize 12 SLsend 19 MtSensO 26 ConMTAD
6 SLlisten 13 MtrelMb 20 TNexit
No code in this image references any of it (measured). That zero was
calibrated before being believed: the same scan, on the same run, correctly
found the four self-name loads above and the three protosw bases. A PC-relative
lea cannot reach either — the nearest code is 0x49FE4 bytes away, far outside
±32 KB.
There is also no SLinit, no SLfork and no Mt* routine anywhere in the
image, and this list does not match the socket API that is here.
So (inferred, two readings, neither proven): either it is the trace-name table of a companion module linked into the product but absent from this bank set, or it is a build artefact.
Why it is worth keeping. The names describe the intended SINTRAN path —
ConnectToSintran, ConMTAD, MtRqCnct/MtRqDcnct (request connect and
disconnect), MtRead, MtWrite, MtSensI/MtSensO, MtResMb/MtrelMb
(reserve and release mailbox), FindFreeMailbox, TNexit. That is a
mailbox-based terminal interface, and MTAD reads as MT + TAD.
This supports but does not prove that the telnet payload towards SINTRAN is TAD. It is a list of names in unreferenced data, not a decoded wire frame. It does not close the open question in section 11.
3c. The TCP transition matrix, fully decoded¶
14 states x 10 events, one longword per cell, at 0x06A0A8. The layout is not
inferred — it is read straight out of FSMR_StateMachineStep @0x0B216:
0xB24C move.w (0xBC,A2),D1 ; state, from TCB+0xBC
0xB256 asl.l #1,D1 ; state*2
0xB25A asl.l #2,D2 (D2=D1) ; state*8
0xB25C add.l D2,D1 ; state*10 <-- 10 events per row
0xB25E add.l (0x14,A6),D1 ; + event
0xB262 asl.l #2,D1 ; *4, longwords
0xB264 lea (0x6A0A8).l,A3
0xB26A move.l (0,A3,D1*1),D3 ; action = matrix[state*10 + event]
A zero cell is an illegal transition, not a no-op. The fall-through packs
state<<16 | event and reports it via NET_PostStatus with code 0x4EE1.
| e0 | e1 | e2 | e3 | e4 | e5 | e6 | e7 | e8 | e9 | |
|---|---|---|---|---|---|---|---|---|---|---|
| s0 CLOSED? | 0 | 1 | 0 | 4 | 6 | 24 | 0 | 0 | 21 | 24 |
| s1 LISTEN | 0 | 1 | 2 | 0 | 6 | 9 | 24 | 24 | 21 | 23 |
| s2 SYN_SENT | 0 | 0 | 5 | 0 | 6 | 9 | 24 | 24 | 21 | 23 |
| s3 SYN_RECEIVED | 0 | 0 | 3 | 0 | 14 | 9 | 24 | 24 | 21 | 23 |
| s4 SYN_RECEIVED | 0 | 0 | 3 | 0 | 14 | 9 | 24 | 24 | 21 | 23 |
| s5 ESTABLISHED | 0 | 0 | 7 | 0 | 14 | 9 | 20 | 19 | 22 | 23 |
| s6 FIN_WAIT_1 | 0 | 0 | 15 | 0 | 24 | 9 | 20 | 25 | 22 | 23 |
| s7 FIN_WAIT_2 | 0 | 0 | 16 | 0 | 24 | 9 | 20 | 24 | 22 | 23 |
| s8 TIME_WAIT | 0 | 0 | 18 | 0 | 24 | 9 | 20 | 25 | 22 | 23 |
| s9 CLOSE_WAIT | 0 | 0 | 17 | 0 | 8 | 9 | 20 | 19 | 22 | 23 |
| s10 CLOSING | 0 | 0 | 10 | 0 | 25 | 9 | 20 | 25 | 22 | 23 |
| s11 LAST_ACK | 0 | 0 | 11 | 0 | 25 | 9 | 20 | 25 | 22 | 23 |
| s12 terminal* | 0 | 0 | 13 | 0 | 25 | 9 | 12 | 25 | 22 | 23 |
| s13 CLOSED? | 0 | 1 | 0 | 4 | 6 | 24 | 0 | 25 | 21 | 24 |
The anchor. Action 8 (CloseWaitToLastAck) appears in exactly one cell of
the whole matrix — s9/e4. CLOSE_WAIT + close → LAST_ACK is the one transition
RFC 793 pins unambiguously, so that single occurrence fixes the row and column
origin. Every other label above hangs off an alignment that anchor proves.
Events — each confirmed by finding a caller that passes it¶
The event is the leading argument, staged as move.l #N,(d16,A6) immediately
before the call. All 14 call sites were swept (measured):
| Event | Meaning | Passed by |
|---|---|---|
| e1 | passive OPEN (listen) | TCP_UsrReq @0x0DD18 |
| e2 | SEGMENT ARRIVES | FSMR_ReceiveData @0x180BA |
| e3 | active OPEN (connect) | TCP_UsrReq @0x0DEC0 |
| e4 | CLOSE | TCP_UsrReqClose @0x0C55A |
| e5 | TIMEOUT | TIMER_SlowTick @0x16C4A |
| e6 | RECEIVE | TCP_UsrReq @0x0D608 |
| e7 | SEND | TCP_UsrReq @0x0D7F4 and 0x0E7FC, plus SO and SL |
| e8 | ABORT | TCP_UsrReq @0x0DB40, 0x0DBB2, 0x0DC10, 0x0E292 |
| e0 | never used | nothing — column is all zero |
| e9 | nothing raises it | no caller anywhere in the image |
This settles two long-standing open items:
- Nothing raises event 9. Its column is fully populated (action 23,
Event9ToClosed, in every open state), so the table supports it — but no code in this image passes it. The sweep recovered 8 of the 9 live events correctly, so that zero is calibrated. Same shape as the unreferenced name table in §3b: a capability whose driver is not in this bank set.
Correction — the state identities, settled by entry edges¶
An earlier version of this section claimed s12 = TIME_WAIT, on the grounds
that it is the only row whose RECEIVE cell uses action 12,
FSMR_Act12_TimeWaitReceive. That was circular: I had assigned that action
its name myself, then used the name as evidence for the state it serves. The
name was the guess, not the evidence.
The real evidence is which transitions enter each state. Every action writes
the new state as a word into its own frame at (0x18,A6); the caller copies it
to TCB+0xBC. Sweeping every move.w #imm,(0x18,A6) in the action region gives
action → new state, and combining that with the matrix gives the whole graph:
| State | Entered from | Identity |
|---|---|---|
| s1 | s0, s13 / OPEN-listen | LISTEN |
| s2 | s0, s13 / OPEN-connect | SYN_SENT |
| s3 | s2 / SEGMENT | SYN_RECEIVED (simultaneous open) |
| s4 | direct move.w #4,(0xBC,A0) in Act02 @0x190C2 |
SYN_RECEIVED (from LISTEN) |
| s5 | s2, s3, s4 / SEGMENT | ESTABLISHED |
| s6 | s3, s4, s5 / CLOSE | FIN_WAIT_1 |
| s7 | s6 / SEGMENT | FIN_WAIT_2 |
| s8 | s6, s7, s10 / SEGMENT | TIME_WAIT |
| s9 | s2, s3, s4, s5 / SEGMENT | CLOSE_WAIT |
| s10 | s6 / SEGMENT | CLOSING |
| s11 | s9 / CLOSE | LAST_ACK |
| s12 | TIMEOUT from every state, plus s10, s11 / SEGMENT | terminal, not an RFC state |
| s13 | ABORT / CLOSE / ev9 from everywhere | CLOSED |
s8 is TIME_WAIT. RFC 793 has exactly one state reachable from all three of
FIN_WAIT_1, FIN_WAIT_2 and CLOSING, and that is TIME_WAIT. Three incoming edges
from those three states fit nothing else.
s12 is not an RFC state. It is entered by a TIMEOUT in every single state
and by the completing segment in CLOSING and LAST_ACK; inside it only RECEIVE
and TIMEOUT do anything, and both either stay put or fall to s13. That reads as
"finished, waiting for the socket layer to reap it" — one of the three states
this implementation has beyond RFC 793's eleven. Not proven; the entry and
exit edges are what is measured.
s3 and s4 are both SYN_RECEIVED and the split is real: s4 is entered by a
direct move.w #4,(0xBC,A0) inside Act02 (LISTEN + segment — the passive open),
while s3 arrives through the out-slot from Act05 (SYN_SENT + segment — the
simultaneous open). Two slots because two arrival paths.
stateDiagram-v2
[*] --> CLOSED
CLOSED --> LISTEN: passive OPEN
CLOSED --> SYN_SENT: active OPEN
LISTEN --> SYN_RCVD_p: segment (SYN)
SYN_SENT --> SYN_RCVD_a: segment
SYN_SENT --> ESTABLISHED: segment
SYN_RCVD_p --> ESTABLISHED: segment
SYN_RCVD_a --> ESTABLISHED: segment
SYN_SENT --> CLOSE_WAIT: segment (FIN)
SYN_RCVD_p --> CLOSE_WAIT: segment (FIN)
ESTABLISHED --> CLOSE_WAIT: segment (FIN)
ESTABLISHED --> FIN_WAIT_1: CLOSE
SYN_RCVD_p --> FIN_WAIT_1: CLOSE
FIN_WAIT_1 --> FIN_WAIT_2: segment
FIN_WAIT_1 --> CLOSING: segment
FIN_WAIT_1 --> TIME_WAIT: segment
FIN_WAIT_2 --> TIME_WAIT: segment
CLOSING --> TIME_WAIT: segment
CLOSE_WAIT --> LAST_ACK: CLOSE
LAST_ACK --> REAPING: segment
CLOSING --> REAPING: segment
TIME_WAIT --> REAPING: timeout
REAPING --> CLOSED: timeout / receive
CLOSED --> [*]
note right of REAPING
s12 - not an RFC 793 state.
Reached by a timeout in ANY state.
end note
*s12 in the matrix above is the REAPING node in this diagram — the name is
descriptive, not recovered from the firmware.
Note also that e4 = CLOSE and e7 = SEND, which matches the correction made earlier from the PRU dispatch evidence — two independent routes to the same answer.
3d. The two ioctl bitmaps — a trace-control interface¶
SO_IoctlDispatch @0x0C580 dispatches on (0x8,A0). Case 0 is trace control,
and that is what the two bitmaps are (measured).
It reads a 32-bit value from the argument buffer (as two words at buffer+0x2C and +0x2E), records its sign, then takes the absolute value:
| Value range | Bitmap |
|---|---|
value & 0xFFC0 == 0x4980 |
g_enmaTraceEnableBits_64 @0x02C954 |
value & 0xFFC0 == 0x4E80 |
g_aipTraceEnableBits_64 @0x036F7E |
bit index = value & 0x3F 64 categories per map
byte = base[(value & 0x3F) >> 3]
bit number = 7 - (value & 7) <-- from `not.b` before bclr/bset
bclr always, then bset if the value was positive
So the sign is the verb and the magnitude is the noun: a positive value enables a trace category, a negative one disables it. Both maps are all zero in the image — everything is off until the host turns it on.
The MSB-first bit numbering matters. not.b D0 followed by bclr.b D0,(A3)
selects bit 7-(n mod 8), because the 68000 takes the bit number mod 8. An
implementation using LSB-first addresses the wrong category and looks almost
right.
What proves these are trace gates rather than configuration is who reads them:
| Bitmap | Read by |
|---|---|
g_enmaTraceEnableBits_64 |
ENMA_TraceIfEnabled @0x064B8 (btst at 0x06538), ENMA_Service @0x066D2 |
g_aipTraceEnableBits_64 |
AIP_TraceIfEnabled @0x08958 (btst at 0x0897A), AIP_Initialise @0x08A6A |
AIP_TraceIfEnabled is the shape to recognise: mask the category with 0x3F,
asr.l #3 for the byte, not.b for the bit, btst; if clear, return and emit
nothing; if set, copy a 20-byte record and call the XMSUP emitter at 0x028D54.
The AIP trace strings sit immediately after that bitmap from 0x036F86 —
"$AIP: PIOC ERROR", "$AIP: no more fragments for ARP".
That last string is worth noting: it is the one that originally produced the
wrong name AIP_ArpOutOfFragments for 0x08E48. It is the out-of-buffer failure
message inside what is really ARP_SendPacket — a string tells you what a
routine prints on a bad day, not what it is for.
Status words in this routine: 0x4EC3 (ok/ignored), 0x4ED1 (bad argument).
3e. The server name is built by patching the literal¶
XMSG_SelectTcpServerName @0x02152A answers the "*TCP0 vs *TCPGATE. vs
*TCP" question, and the answer is that they are not three separate names.
There is one 18-byte literal at 0x07BDD8:
0x7BDD8 * T C P 0 * T C P G A T E . * T C P
^-- 0x7BDD8 ^-- 0x7BDDD
^-- 0x7BDDC is overwritten at run time
The code writes into it (measured). Byte 0x07BDDC — the 0 of *TCP0 — is
patched with the interface number as an ASCII character:
0x21536 tst.l (0x14,A6) ; interface number, parameter 1
0x2153C moveq #15,D0 ; cmp ; bge -> error ; range is 0..15
0x21544 move.l #0x4F0A,D0 ; return ; out-of-range status
0x21552 moveq #9,D0 ; cmp ; blt ; 0..9 or 10..15 ?
0x2155E add.l #0x30,D1 ; '0'+n
0x21564 move.b D1,(0x7BDDC).l <<< self-modifying
0x21570 sub.l #0x0A,D1 ; add.l #0x41,D1 ; 'A'+(n-10)
0x2157C move.b D1,(0x7BDDC).l <<< same byte
0x2158A move.l #3,(0x2E,A6) ; upper 3 -> length 4
0x21594 move.l #4,(0x2E,A6) ; upper 4 -> length 5
The length becomes the upper bound of a PLANC descriptor built over base 0x07BDD8, so:
| Interface | Name | How |
|---|---|---|
| 0 | *TCP |
length 4 — stops before the patched byte |
| 1–9 | *TCP1 … *TCP9 |
patched byte, length 5 |
| 10–15 | *TCPA … *TCPF |
patched byte, length 5 |
Same convention as ENNS0/ENNS<thumbwheel> for the network server — the digit
in the name is the binding to the card. The name space allows 16 even though
the ND-100 drives at most four Ethernet II controllers.
The third form is not *TCPGATE. At 0x0217E0 the descriptor uses base
0x07BDDD with upper = 0x0C, i.e. length 13 = *TCPGATE.*TCP, and it is taken
only when the interface number is zero. Reading the literal by eye and stopping
at the . gives the wrong name — take the length from the descriptor, never from
where a name looks like it ends.
This matters for an emulator or HLE: 0x07BDD8 is not read-only constant data. Treating that page as immutable, or caching the string, breaks every interface except number 0 — and breaks it silently, because interface 0 is the one case where the patched byte is never read.
*TCPDSERVER at 0x02D771 is a separate name used by TCPD_ClientSession
@0x08092, unrelated to this scheme.
4. The receive path¶
flowchart TD
W([wire]) --> L["Lance_InterruptService @0x465C<br/>Am7990 CSR0 at 0xEF00A0"]
L --> ES["ENMA_Service @0x65FA"]
ES --> GF["ENMA_GetReceivedFrame @0x67CC"]
GF --> RL["EII_ReceiveFrameLoop @0xA382<br/>splits on EtherType"]
RL -->|"EtherType 0x0800<br/>and dest is ours"| IPI["IP_Input @0x9C1E"]
RL -->|"EtherType 0x0806<br/>and target IP is ours"| ARPI["ARP_Input @0x91C0"]
RL -->|"EtherType 0x9002"| CFG["config-test responder<br/>answers 1 in 8"]
RL -->|"anything else,<br/>or not addressed to us"| DROP["BUF_FreeOne<br/>counted and dropped"]
RL -->|"ring empty,<br/>once per pass"| DQ["IP_DeliverQueued @0xA258"]
DQ -->|proto 1/6/17| SOCK
ARPI --> ASP["ARP_SendPacket @0x8E48<br/>reply to a who-has"]
ARPI --> IPF["IP_FragmentAndSend @0x9568<br/>flush the packet held<br/>waiting on this address"]
IPI --> AGE["AIP_AgeReassemblyQueue @0x93BE"]
IPI --> REASM["AIP_ReassembleFragment @0x994E"]
IPI --> SUM["NET_OnesComplementSum @0x10E8E"]
IPI -->|proto 1| ICMP["ICMP_EchoReply @0x9486"]
IPI -->|proto 6| TCPIN["TCP_Input @0xBD2A"]
IPI -->|proto 17| UDPIN["UDP_Input @0xF5A2"]
IPI -->|raw| RAWIN["RAWinput @0xEAAE"]
ICMP --> IPO["IP_Output @0x97F0"]
TCPIN --> FSM["FSMR state machine"]
UDPIN --> SOCK["socket layer"]
RAWIN --> SOCK
Measured detail worth keeping:
EII_ReceiveFrameLoophas exactly three EtherType arms — 0x0800 (IP), 0x0806 (ARP) and 0x9002 (Ethernet configuration test), compared at 0xA3B6, 0xA442 and 0xA4AC. Anything else is freed. (An earlier draft of this document said it had "exactly two callees, ARP_Input and IP_Input". That was wrong — it was read off the tree printout rather than off the dispatch.)- There is no forwarding path. The IP arm accepts a packet only if the
destination at IP header+0x10 equals
g_myIpAddress, or its low byte is 0x00 or 0xFF (broadcast). Anything else is counted intog_rxStatsBlockand freed. This card is a host, not a router. - The ARP arm calls
ARP_Inputonly when the ARP target protocol address at ARP+0x18 is ours. ReachingARP_Inputonly from the 0x0806 arm is the second, independent proof of that name. - The 0x9002 arm answers roughly one in eight:
moveq #7,D2 / move.w (g_configTestReplyCounter),D3 / and.w D2w,D3w / bne skip. The reply carries our MAC, the broadcast MAC, our IP, and a 19-byte banner"ND/EII-TCP-". IP_DeliverQueuedis called from one place — the branch taken whenENMA_GetReceivedFramereturns no frame, i.e. once per pass after the ring drains. It pops one datagram offg_ipInputQueueHead, stashes the source address from IP header+12 into a global, and demuxes on header+9.AIP_AgeReassemblyQueueis called fromIP_Input, not from a timer interrupt. Fragment reassembly ages off the receive path — no packets arriving means no ageing (measured).- The ICMP echo reply goes back out through the ordinary
IP_Output, not through a shortcut (measured).
5. The transmit path¶
flowchart TD
subgraph callers["everything that sends"]
A1["ICMP_EchoReply @0x9486"]
A2["TCP @0xA950"]
A3["RAWusrequest @0xED40"]
A4["MAIN_UdpOutput @0xF998"]
A5["FSMR_SendPacket @0x164D4"]
A6["FSMR @0x16904"]
end
callers --> IPO["IP_Output @0x97F0<br/>(thin entry — its only callee is below)"]
IPO --> FRAG["IP_FragmentAndSend @0x9568"]
FRAG --> RES["ARP_Resolve @0x9048"]
FRAG --> TX["ENMA_TransmitFrame @0x6748"]
FRAG --> SUM["NET_OnesComplementSum"]
FRAG --> ALLOC["BUF_AllocTry @0x14EDE"]
RES -->|cache miss| ASP["ARP_SendPacket @0x8E48<br/>broadcast a who-has"]
RES -->|hit| TX
ASP --> TX
TX --> W([wire])
IP_Output at 0x97F0 has six callers and exactly one callee. The real work is
IP_FragmentAndSend at 0x9568, and its identity is not in doubt (measured):
0x95C0 move.w (g_interfaceMtu).l,D1 ; the MTU
0x95C6 sub.w #0x14,D1 ; minus the 20-byte IP header
0x95D0 andi.l #0xFFF8,(0x1E,A6) ; round DOWN to a multiple of 8
; -> RFC 791 fragment-offset granularity
0x95F0 move.w #0x4500,(A0) ; IP version 4, IHL 5, TOS 0
0x95F8 add.l #0x14,D1 ; header back on for the total length
ARP_Resolve at 0x9048 is the classic arpresolve (measured):
- if
dest & 0xFFis 0 or 0xFF → broadcast; copy the 6 bytes atg_broadcastMacAddressand return "resolved". - else, if a gateway is set, compare
myIp & maskagainstdestIp & mask; different subnet → send it tog_defaultGatewayIpinstead. - walk the cache list at
g_arpCacheListHead, matching entry+4 against the target, with a move-to-front on a hit.
6. Networking globals recovered this pass¶
All read out of ARP_Input, ARP_Resolve and IP_FragmentAndSend (measured).
| Address | Name | Size | Evidence |
|---|---|---|---|
| 0x02C82C | g_myMacAddress |
6 | ARP_Input compares it against the ARP sender HA at packet+8 |
| 0x036B7A | g_myIpAddress |
4 | compared against the ARP target IP at packet+0x18 |
| 0x036B7E | g_defaultGatewayIp |
4 | substituted when the destination is off-subnet |
| 0x036B84 | g_interfaceMtu |
2 | minus 20, rounded to /8, is the max fragment payload |
| 0x036B90 | g_ipPacketsSentCount |
2 | addq.w #1 at the head of IP_FragmentAndSend |
| 0x036B96 | g_subnetMask |
4 | ANDed with both addresses in the off-subnet test |
| 0x036BB0 | g_broadcastMacAddress |
6 | copied out on a broadcast destination |
| 0x036F7A | g_arpCacheListHead |
4 | head of the ARP list, walked by both ARP routines |
| 0x02C852 | g_lanceIsrDisabled |
2 | tested first in the LANCE ISR; non-zero bails out |
| 0x02C85A | g_lanceStatsBlock |
— | +0x18 counts MISS (long), +0x34 counts CERR (word) |
| 0x02C946 | g_lanceLatchedErrorBits |
2 | BABL/MERR are OR-ed in before the chip is stopped |
| 0x00EF00A0 | LANCE_RDP |
2 | the Am7990 register data port |
| 0x036B92 | g_configTestReplyCounter |
2 | low 3 bits gate the 0x9002 reply — 1 in 8 |
| 0x036B9A | g_ipInputSourceAddress |
4 | source IP stashed for the protocol handler |
| 0x036BA2 | g_ipInputQueueHead |
4 | head of the pending IP input queue |
| 0x037064 | g_ipProtocolDemuxMax |
1 | bound checked before indexing the 40-slot table |
| 0x03716A | g_tagRecvIp / g_tagRecvArp / g_tagRecvConfigTest |
3 each | the trace tags "RI ", "RA ", "RN " |
| 0x037173 | g_stationBannerString |
19 | "ND/EII-TCP-", sent in the 0x9002 reply |
| 0x0752CC | g_rxStatsBlock |
— | +0 and +0x0C count not-for-us drops, +0x84 counts ARP and config-test |
g_myIpAddress, g_subnetMask and g_broadcastMacAddress already carried those
names from an earlier pass. Arriving at the same three addresses from a
completely different routine is independent confirmation, not a coincidence.
7. The telnet session¶
sequenceDiagram
participant T as TELNET_Main @0x1C10E
participant SI as TELNET_ServerInit @0x1BCF4
participant AL as TELNET_AcceptLoop @0x1BA90
participant SL as SLib (socket API)
participant TR as SLtrace
participant X as XMSG towards SINTRAN
T->>SI: bring the server up
SI->>SL: SLsocket / SLbind / SLlisten
SL->>TR: every call goes through the trace layer
TR->>X: XMSG request
T->>AL: accept loop
AL->>SL: SLaccept
AL->>X: XMSG_SelectTcpServerName @0x2152A
Note over AL,X: a session is handed to<br/>XMSG_TelnetSessionEntry @0x21850
AL->>SL: SLsend / SLrecv per connection
AL->>SL: SLshutdown + SLclose on teardown
The socket API is the full BSD set, all named and all measured:
SLsocket 0x1D170 · SLbind 0x1D328 · SLlisten 0x1D3DC · SLaccept 0x1D4CE ·
SLsend 0x1D718 · SLrecv 0x1D8C6 · SLsense 0x1DA54 · SLshutdown 0x1DB90 ·
SLclose 0x1DCB8 · SLioctl 0x1DE08 · SLsleep 0x1D062
A warning that already cost time once: reachability in this call graph is
not a runtime path. A breadth-first search from the telnet data path to XMSG
appears to arrive via SLsend, but it only gets there through four SLtrace
hops. SLsend's only non-trace callee is 0x014370, in the TCP module. Do not
read a path off the graph and call it the data path.
8. Call trees¶
Abridged. NAME is a routine that has been identified; MODULE:ADDR is one that
has only been placed in a module. ... means the subtree was already printed
above. Routines with 12 or more callers are collapsed out so the shape stays
readable — they are listed at the top of the companion file.
8.1 From the main loop¶
MAIN_MainLoop @0x103E6
+- ENMA_Service
| +- ENMA:04E0E
| +- ENMA:055F6
| | `- ENMA:04BD0
| +- ENMA:05666
| | +- ENMA_ReportError
| | +- ENMA:04C98
| | `- ENMA:0561E
| +- ENMA:057FA
| +- ENMA:06824
| `- XMSGsup:28D54
+- EII_ReceiveFrameLoop
| +- ENMA_TransmitFrame
| +- ENMA_GetReceivedFrame
| +- ARP_Input
| `- IP_Input
`- TIMER_SlowTick
8.2 The receive path in full¶
EII_ReceiveFrameLoop @0xA382
+- ENMA_TransmitFrame
+- ENMA_GetReceivedFrame
+- ARP_Input
| +- TCPD:08958
| +- AIP:08A34
| +- ARP_SendPacket
| | +- ENMA_TransmitFrame
| | `- BUF_AllocTry
| +- AIP:08FA6
| `- IP_FragmentAndSend
| +- ENMA_TransmitFrame
| +- ARP_Resolve
| +- NET_OnesComplementSum
| `- BUF_AllocTry
+- IP_Input
| +- AIP_AgeReassemblyQueue
| +- ICMP_EchoReply
| | +- IP_Output
| | `- NET_OnesComplementSum
| +- AIP_ReassembleFragment
| +- TCP_Input
| | +- NET_ChecksumBuffer
| | +- TCP:0B42A
| | | +- SO:12000 -> SO_NotifyHost -> SO_SendToHost
| | | +- SO_Detach -> SO_FreePcb / SO_DetachCleanup
| | | `- FSMR:184E6 -> FSMR_SendPacket
| | +- TCP:0BB90 -> TCP:0A950 -> IP_Output
| | `- FSMR:16CBA
| +- RAWinput -> BUF_AllocWait
| `- UDP_Input
`- IP_DeliverQueued (once, after the ring drains)
8.3 TCP output¶
FSMR_TcpOutput @0x16FEA
+- SO_NotifyHost
| `- SO_SendToHost
| +- SO:10F2C -> XMSGsess:26BF0
| +- SO:1245A
| +- XMSGsess:2625C -> XMSGsess:26F78 / 26FFC / 270A4
| `- XMSGsess:26A22
`- FSMR_SendPacket
+- IP_Output -> IP_FragmentAndSend -> ENMA_TransmitFrame
+- BUF_AllocWait
+- NET_OnesComplementSum
`- BUF_AllocTry
The two branches are the whole design in miniature: SO_NotifyHost goes up to
SINTRAN over XMSG, FSMR_SendPacket goes down to the wire.
8.4 The user-request entry¶
TCP_UsrReq @0xD510 (dispatches via tcp_pru_dispatch @0x75490
+- FSMR_StateMachineStep into its own arms at 0xD5EC-0xE2BA)
| `- SO:12000 -> SO_NotifyHost -> SO_SendToHost
+- SO_Attach
| +- SO_AllocPcb
| +- SO_Reserve
| `- FSMR:15C98
`- TIMER_SlowTick
9. The naming scheme¶
Where a routine could be identified, it got a real name. Where it could only be
placed, it got <MODULE>_<role>_<ADDRESS> — which says exactly how much is known
and no more. The address is in the name so a name can always be checked against
the listing.
| Role | Means |
|---|---|
leaf |
calls nothing |
util |
small, several callers, inside one module |
sub |
ordinary subroutine of that module |
tiny |
a handful of instructions |
get |
field accessor: movea.l (0x14,A6),A0 then move.w (d,A0),D0 |
buf |
works on buffers |
msg |
works on XMSG messages |
copy |
block move |
rep |
reporting/formatting |
math |
arithmetic helper |
stub |
prologue and epilogue only, no body |
hub |
many callers and many callees |
uncalled |
no incoming edge found anywhere in the image |
sock |
socket-structure handling |
Special cases:
FaultStub_vecNN— the ~200 two-bytetrap #1stubs the vector table points at.IgnoreIrq_XXXXX— 21 one-instruction4E 73(rte) stubs at 0x4074–0x409C. These do nothing but return from the interrupt. They are not trap stubs; the byte pattern was checked (measured).PIOCOS_*,Plance*,ND_*— the kernel and PLANC runtime, byte-identical to the ENCOS image, so the ENCOS names transfer with proof.
10. Corrections made in this pass¶
Recording these because each one was a plausible name that turned out to be wrong, and the way they were wrong repeats.
| Was | Is | How it was caught |
|---|---|---|
AIP_ReportError @0x91C0 |
ARP_Input |
compares 6 bytes at packet+8 against our MAC, the long at packet+0x18 against our IP, then walks the ARP cache — those are the ARP field offsets |
AIP_ReportIcmpMessage @0x9568 |
IP_FragmentAndSend |
writes #0x4500, and rounds MTU-20 down with andi.l #0xFFF8 |
AIP_buf_09048 @0x9048 |
ARP_Resolve |
broadcast test, off-subnet gateway substitution, cache walk with move-to-front |
AIP_ArpOutOfFragments @0x8E48 |
ARP_SendPacket |
called by both ARP routines, and calls ENMA_TransmitFrame |
| 5 tables read as call tables | branch tables inside one routine | their targets are not prologues; they sit inside IP_Input / TCP_UsrReq |
| 4 duplicate function names | fixed | two functions shared a name, so the address suffix lied for one of them |
IP_InputAlt @0xA258 |
IP_DeliverQueued |
called only on the "ring is empty" branch; pops g_ipInputQueueHead |
| "receive loop has two callees" | three EtherType arms | read off the tree printout instead of the dispatch code |
| "14-entry table = one arm per state" | indexed by TIMER NUMBER | TIMER_SlowTick loops 0..0xD over the timer array at TCB+0xC0 and passes the index as parameter 2. I inferred "14 = 14 states" from the count alone |
FSMR_Act09_TimeoutToClosed |
FSMR_Act09_TimerExpiry |
it switches on which timer fired, not on the state |
s12 = TIME_WAIT |
s8 = TIME_WAIT |
circular: I used a name I had assigned to action 12 as evidence for the state it serves. Entry edges (s6/s7/s10 → s8) settle it |
| action table: "25 of 26 land on a prologue" | all 26 do; 25 are distinct | the sweep collected targets into a set, so slots 0 and 24 sharing FSMR_NoTransition collapsed and the total came out one short. Caught by the RetroCore regression test, not by re-reading |
FSMR_Act12_TimeWaitReceive |
FSMR_Act12_State12Receive |
s12 is not TIME_WAIT, so the old name asserted the thing it was used to prove |
The repeating failure: naming a routine after a string it references. The
first three above all came from that. A string tells you what a routine prints,
not what it is. IP_Output's only callee being called "report ICMP message" is
what gave it away — a reporting routine does not transmit frames.
The check that catches it every time is the one in this document's method: look at who calls it and what it calls before believing a name.
11. Still open¶
| Item | State |
|---|---|
| The telnet payload towards SINTRAN — is it TAD? | (unknown) still not proven. The unreferenced name table in section 3b (ConMTAD, MtRead, ConnectToSintran) points that way but is not evidence of wire format — it needs a capture |
TCP state s12 |
(inferred) a terminal "finished, waiting to be reaped" state, entered by a TIMEOUT in every state — not one of RFC 793's eleven. s8 is settled: TIME_WAIT. See the correction in §3c |
| What raises state-machine event 9 | answered: nothing in this image does. All 14 call sites swept, calibrated — see §3c |
What writes g_tcpDispatchIndex |
answered: TCP_Input does, at 0x0C26A. The old note said "three references, all reads" — but that instruction has the absolute long as the DESTINATION. Renamed g_tcpSelectedActionNumber; it holds the action number between the matrix lookup and the call. TCP_Input carries its own inlined copy of the lookup with event 2 hardcoded (addq.l #2) |
The two bitmaps in SO_IoctlDispatch |
answered: 64-bit trace-category enable maps, one for ENMA and one for AIP — see §3d |
*TCP0 vs *TCPGATE. vs *TCP |
answered: one literal, patched at 0x07BDDC with the interface number. *TCP = interface 0, *TCPn = interface n, *TCPGATE.*TCP (length 13) = the gateway form. See §3e |
| 65 routines with no reference of any kind | (inferred) some are runtime-planted process entry points, the rest look like unused library code. 31 of the original 96 are now explained — see section 3a |
12. Where the rest of it is written down¶
| File | Contents |
|---|---|
TCP-SER-D02-CALL-TREE-FULL.txt |
the full depth-4 trees for 15 entry points, 1227 lines |
TELNET-XMSG-SIN.md |
how the telnet server talks to SINTRAN over XMSG, with the corrections section |
TCP-SER-D02-FUNCTION-CATALOG.md / .csv |
every entry point, one row each |
theghidra-plancskill |
the PLANC-MC conventions and the recurring mistakes |
thend-ethernet-iiskill |
the card, the LANCE, the BPUN merge |