Files
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

252 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`) |