mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
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.
252 lines
13 KiB
Markdown
252 lines
13 KiB
Markdown
# 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`) |
|