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.
13 KiB
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,sendtoandrecvfromall return normally from the process thejumphandler runs in. The fd is process-local (socketreturned 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_processchild: one attempt (dwt/tcpip/launcher.asm+socktest.asm) returned-1fromsocket()(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_processis 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:
buf = malloc(0x10000); keep the returned bit pointer ina11.fd = socket(2,2,0); keep it ina9.sendto(fd, RRQ, 28, 0, &server, 16), where the RRQ isdwt/tcpip/probe.bininoctetmode andserveris{AF_INET, htons(69), 192.168.99.1}.recvfrom(fd, recv, 2048, 0, &from, &fromlen); accept only opcode 3 (00 03on the wire, read as the 16-bit word0x0300).- Copy
len-4payload bytes tobuf, send ACK00 04 <block>to thefromaddress returned byrecvfrom, advancebufby(len-4)*8bits. - 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
- Pointers are bit addresses. After copying
nbytes, the destination cursor must advance byn*8. Addingnputs block 2 atdest+64for a 512-byte block, which looks like a copy corruption. - The receive buffer and the
fromsockaddr must not overlap. A bit offset of0x800is 256 bytes, not 2048: with the receive buffer at0187B000,from=0187B800sat 256 bytes into the 2048-byte buffer and the stack'sfromwrite corrupted the payload at offset 252. The working layout is recv01876000(2048 bytes),from0187A000,fromlen0187A080, ACK0187A100.
5. What a loader must do to fetch a COFF at runtime
- Deliver a small bootstrap with rdebug
poke+jump(orcreate_processif a child is needed for the loader itself). - In the rdebug process:
malloc(size)for the COFF, thensocket(2,2,0),sendtoan RRQ for the COFF name, and therecvfrom/ACK loop fromtftpc.asm. Use a receive buffer of at least 2048 bytes, separate from thefrom/fromlen/ACK cells. - Append blocks at
dest + total*8bits, notdest + total. - Stop on a short block;
close(fd). No timeout is built in — the server is local and the payload blocks inrecvfrom. Addsetsockopt(SO_RCVTIMEO)or a retry if a lossy link matters. - Hand the buffer to the loader resident at bit
01880000(dwt/loader/build/loader.bin, 1220 B): write the descriptor at01882C00—u16 0x4C44, u16 0x5231, u32 buf, u32 len_bytes, u32 mode=1— thenmove a14,-*sp,1; calla 01880000hwithA8=0(fixed descriptor) or the descriptor's bit address. The loader parses the COFF, copies.text/.data, zeroes.bssand callscreate_processitself. The full contract, including the status block at01882D00, is indwt/notes/LOADER_CROSS.md. - 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 with01880000–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;
recvfromblocks until a packet arrives. The server side has 10 retries at 60 s. A lost packet hangs thejump. - Concurrency: the server is single-threaded. An un-ACKed transfer (the
first
udptestleft one) blocks later RRQs until it times out; restart the server or use a per-transfer server. - Process: rdebug process. A
create_processchild returned-1fromsocket()in the one attempt; if the loader is a child, provesocket()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) |