Files
vt1200/dwt/notes/TCPIP.md
T
SDD 01923ecb69 dwt/tcpip: map the socket API and fetch a file over TFTP into RAM
The firmware's tftp_open hangs in its resolver from the rdebug process, so
the payload speaks RFC 783 over the firmware socket API: socket(2,2,0),
sendto(RRQ), recvfrom(DATA)/ACK into a malloc'd buffer, close. Verified
live: 600 B fetched from 192.168.99.1 into the malloc buffer, server RRQ
logged, and rdebug peek matches probe.bin byte-for-byte.

Notes: dwt/notes/TCPIP.md (function table, ABI, limits) and the
loader/transport hand-off in dwt/notes/LOADER_CROSS.md.
2026-10-18 15:24:34 +03:00

13 KiB
Raw Blame History

VT1200 TCP/IP and a working TFTP fetch

Date: 2026-10-18. Firmware vt1200_v22.bin md5 b46a3e442ccb86134d2356bd70d22fd2 (unmodified). No driver edits, no ROM patch. The emulator ran the documented VT1200_SKIP_LEVEL1=1 divergence with a private -cfg_directory/-nvram_directory, the LANCE on taptun, BOOTP from a private server, and dwt/tcpip/probe.bin served over TFTP.

Result: a payload (dwt/tcpip/tftpc.asm) fetched a 600-byte file from 192.168.99.1:69 into a malloc'd buffer, in the rdebug process (ordinary jump, no create_process), and peek returned it byte-for-byte:

stage=4 fd=1 buf=0x0095bbd0 total=600 last=92 block=0x0200 done=0x05a5a5a5
OK: 600 bytes at 0x0095bbd0 match dwt/tcpip/probe.bin
sha256=9662e62b45802984e5eef9c7410ad7482645eb5ff77805f0406d248e35333359

Server-side RRQ (fresh server log for that run):

15:23:48  RRQ 192.168.99.2:1025 requested 'dwt/tcpip/probe.bin' (600 bytes)

The firmware's own tftp_open is not usable from the rdebug process: it never returns and emits no RRQ (details in §3). The fetch is therefore a minimal RFC 783 client built on the firmware's socket API — the allowed fallback, not a mock.


1. Stack architecture

TCP/IP is an in-process library with a kernel-lock wrapper, not a separate service with an IPC protocol. Every socket call is an ordinary ROM function taking stack arguments in the firmware convention:

MOVE  A14,-*SP,1                 ; reserve the slot the callee restores
MOVI  argN,A0 / MOVE A0,*A14+,1  ; last argument first, upward via A14
...
MOVI  arg0,A0 / MOVE A0,*A14+,1  ; first argument last
CALLA target

The wrappers take the per-process lock at [@0x1B3900]+0x1C1 and dispatch through the kernel entry, but from the caller's point of view they are library calls: no registration, no service name, and no X-style attach. The arguments are the normal BSD socket arguments; struct sockaddr is a 16-byte {u16 family; u16 port; u32 addr; u8 zero[8]} in network byte order (the firmware calls htons(69) itself in tftp_open).

Verified entry points

function bit address Ghidra ABI (source order) status
socket 3F5C9A00 07EB9340 socket(domain, type, protocol) -> fd verified live, returns 1
bind 3F5CAA80 07EB9550 bind(fd, sockaddr*, len) from the BOOTP caller
listen 3F5CBCD0 07EB979A listen(fd, backlog) from inetd (backlog=5)
accept 3F5CCB40 07EB9968 accept(fd, sockaddr*, len*) from inetd (NULL,NULL)
sendto 3F5D3D70 07EBA7AE sendto(fd, buf, len, flags, to, tolen) -> n verified live (28-byte RRQ)
recvfrom 3F5D7D50 07EBAFAA recvfrom(fd, buf, len, flags, &from, &fromlen) -> n verified live (516/92-byte DATA)
read 3F5D8E70 07EBB1CE read(fd, buf, len) the rdebug/X read wrapper named in the brief
close 3F5E8930 07EBD126 close(fd) verified live
setsockopt 3F5DD260 07EBBA4C setsockopt(fd, level, name, val*, len) from the BOOTP/X callers
select (candidate) 3F871CE0 07F0E39C select(nfds, r, w, e, timeout) wrapper used by Xlib; not exercised
malloc 3F701230 07EE0246 malloc(bytes) -> bit pointer verified live
create_process 3F706A40 07EE0D48 create_process(entry,name,stack,1,arg) known from the GUI work

connect was not needed and is not in this table: the fetch uses UDP sendto/recvfrom only. The candidates in the same wrapper block (3F5CF8B0, 3F5E43F0) were not exercised, so no ABI is claimed for them.

recvfrom's sixth argument is a pointer to a socklen_t, and the caller must initialise it to 16 before the call. Its fifth argument is a pointer to the source sockaddr. That is exactly how the firmware's own tftp_read passes them (&0x1CB8A0 / &0x1CB920).


2. Per-process requirements

  • rdebug process: works. socket, sendto and recvfrom all return normally from the process the jump handler runs in. The fd is process-local (socket returned 1 in that process).
  • No network context or registration is needed. The terminal already has its address (BOOTP) and the stack is up; the calls are plain library calls.
  • create_process child: one attempt (dwt/tcpip/launcher.asm + socktest.asm) returned -1 from socket() (sfd=FFFFFFFF) even though the PCB was valid (pcb=003FFBD0) and the child had started (stage=2). The machine then went into the boot delay loop. That is not a clean child result, but it is enough to say the working context here is the rdebug process, not a child. Do not assume the X result (create_process is needed) transfers to sockets; it did not in this test.

3. The firmware TFTP client (why it was not used)

The client is dwt_tftp.c compiled into ROM:

function bit address Ghidra ABI
tftp_open 3F5EBC40 07EBD788 tftp_open(host, filename) -> fd, or -1
tftp_read 3F5ED230 07EBDA46 tftp_read(fd, buf, bufsize) -> DATA payload length, 0 at EOF, -1 error
tftp_close 3F5EC860 07EBD90C tftp_close(fd)

tftp_open does, in order: socket(2,2,0), memset a 16-byte sockaddr, sa_family=2, resolve the host (3F5933D0; on -1 it retries through 3F5B8580), htons(69) into the port, build the RRQ in its static buffer (0x1C85A0) with mode octet, and sendto. It returns the fd.

tftp_read receives into its own static buffer 0x1CB960 with length 0x204, checks opcode/block, copies len-4 bytes to the caller's buf, ACKs, and sets @0x1CB940=1 when the block was shorter than 512. Read until it returns 0; if the caller stops after a full 512-byte block, tftp_close sends TFTP ERROR 3 ("Disk full or allocation exceeded") — the behaviour documented in harness/mame/FONT_STORAGE_REPORT.md.

Why it is unusable: calling tftp_open("192.168.99.1","…") from the rdebug process never returns and produces no RRQ. socket() alone works (proved with socktest.asm: fd 1), so the hang is downstream; a direct call to the resolver 3F5933D0 also wedged the service and the machine fell into the boot delay loop. The exact resolver failure was not reduced further, because the socket API is sufficient and the fallback is legitimate. Do not call tftp_open from a payload on this image.


4. The working fetch

Payload: dwt/tcpip/tftpc.asm, built by dwt/tcpip/build.sh to dwt/tcpip/build/tftpc.bin (694 bytes, linked at bit 01870000).

It is a minimal RFC 783 client:

  1. buf = malloc(0x10000); keep the returned bit pointer in a11.
  2. fd = socket(2,2,0); keep it in a9.
  3. sendto(fd, RRQ, 28, 0, &server, 16), where the RRQ is dwt/tcpip/probe.bin in octet mode and server is {AF_INET, htons(69), 192.168.99.1}.
  4. recvfrom(fd, recv, 2048, 0, &from, &fromlen); accept only opcode 3 (00 03 on the wire, read as the 16-bit word 0x0300).
  5. Copy len-4 payload bytes to buf, send ACK 00 04 <block> to the from address returned by recvfrom, advance buf by (len-4)*8 bits.
  6. Repeat while the block was full (512); a short block ends the transfer. close(fd).

Evidence:

# server (dwt/tcpip/serve_probe.py, fresh log)
15:23:48  RRQ 192.168.99.2:1025 requested 'dwt/tcpip/probe.bin' (600 bytes)

# MAME-side markers (probe.lua)
TCPC  cstage=00000004 cfd=00000001 cbuf=00B5BCD0 ctotal=00000258 \
      clast=0000005C cblock=00000200 cdone=05A5A5A5

# rdebug peek + byte comparison (dwt/tcpip/verify.py)
stage=4 fd=1 buf=0x0095bbd0 total=600 last=92 block=0x0200 done=0x05a5a5a5
OK: 600 bytes at 0x0095bbd0 match dwt/tcpip/probe.bin
sha256=9662e62b45802984e5eef9c7410ad7482645eb5ff77805f0406d248e35333359

$ cmp dwt/tcpip/run/fetched.bin dwt/tcpip/probe.bin
(no output = identical)

ctotal counts the 512-byte block 1 plus the 88-byte block 2; clast=0x5C is the 92-byte DATA packet (88 + 4-byte header); cblock=0x0200 is the wire word for block 2 (bytes 00 02).

The payload returns 0, so the same rdebug connection and later connections are still live. No breakpoints, no ROM patch, no create_process.

The two bugs that cost the debugging cycles

  1. Pointers are bit addresses. After copying n bytes, the destination cursor must advance by n*8. Adding n puts block 2 at dest+64 for a 512-byte block, which looks like a copy corruption.
  2. The receive buffer and the from sockaddr must not overlap. A bit offset of 0x800 is 256 bytes, not 2048: with the receive buffer at 0187B000, from=0187B800 sat 256 bytes into the 2048-byte buffer and the stack's from write corrupted the payload at offset 252. The working layout is recv 01876000 (2048 bytes), from 0187A000, fromlen 0187A080, ACK 0187A100.

5. What a loader must do to fetch a COFF at runtime

  1. Deliver a small bootstrap with rdebug poke + jump (or create_process if a child is needed for the loader itself).
  2. In the rdebug process: malloc(size) for the COFF, then socket(2,2,0), sendto an RRQ for the COFF name, and the recvfrom/ACK loop from tftpc.asm. Use a receive buffer of at least 2048 bytes, separate from the from/fromlen/ACK cells.
  3. Append blocks at dest + total*8 bits, not dest + total.
  4. Stop on a short block; close(fd). No timeout is built in — the server is local and the payload blocks in recvfrom. Add setsockopt(SO_RCVTIMEO) or a retry if a lossy link matters.
  5. Hand the buffer to the loader resident at bit 01880000 (dwt/loader/build/loader.bin, 1220 B): write the descriptor at 01882C00 — u16 0x4C44, u16 0x5231, u32 buf, u32 len_bytes, u32 mode=1 — then move a14,-*sp,1; calla 01880000h with A8=0 (fixed descriptor) or the descriptor's bit address. The loader parses the COFF, copies .text/.data, zeroes .bss and calls create_process itself. The full contract, including the status block at 01882D00, is in dwt/notes/LOADER_CROSS.md.
  6. Keep the COFF buffer alive; it is not reused by the fetch after the payload returns. The transport's code (01870000), argument area (01875000), receive buffer (01876000–0187A000), from/fromlen/ACK cells (0187A000–0187A120) and markers (0187C000) are all below the loader page, so they do not collide with 01880000–01883200.

The fetch call itself is only malloc/socket/sendto/recvfrom/close; the firmware tftp_open must not be used.


6. Limits and files

  • Buffer: the test allocates 64 KiB; the proof file is 600 bytes. The receive buffer is 2048 bytes and the server's block size is 512, so the largest single DATA packet is 516 bytes. A COFF over TFTP is limited only by the malloc block and the 16-bit block counter (32 MiB at 512-byte blocks).
  • Timeouts: none in the payload; recvfrom blocks until a packet arrives. The server side has 10 retries at 60 s. A lost packet hangs the jump.
  • Concurrency: the server is single-threaded. An un-ACKed transfer (the first udptest left one) blocks later RRQs until it times out; restart the server or use a per-transfer server.
  • Process: rdebug process. A create_process child returned -1 from socket() in the one attempt; if the loader is a child, prove socket() first.
path contents
dwt/tcpip/tftpc.asm the working minimal TFTP client (694 B, bit 01870000)
dwt/tcpip/build/tftpc.bin, .lst, .map built payload and assembler/link records
dwt/tcpip/tfetch.asm firmware-tftp_open attempt (hangs; kept as the negative result)
dwt/tcpip/socktest.asm, restest.asm, udptest.asm the isolation steps: socket, resolver, sendto
dwt/tcpip/launcher.asm create_process launcher used for the child attempt
dwt/tcpip/copytest.asm isolated byte-copy loop
dwt/tcpip/serve_probe.py TFTP server for probe.bin + BOOTP (imports harness/serve.py)
dwt/tcpip/probe.bin 600-byte distinctive probe file
dwt/tcpip/verify.py rdebug peek + byte-for-byte comparison
dwt/tcpip/probe.lua MAME-side marker/PC sampler and buffer check
dwt/tcpip/run/server_final.log the final RRQ line
dwt/tcpip/run/mame_final.log final TCPC marker line (ctotal=0x258)
dwt/tcpip/run/final2_verify.log, fetched.bin peek comparison and the 600 fetched bytes
dwt/tcpip/run/inject_tftpc.log, inject_res.log, inject_child_sock.log negative results: tftp_open, resolver, child socket()
dwt/notes/LOADER_CROSS.md the loader/transport contract (loader at 01880000, descriptor at 01882C00)