harness: the TFTP font 'Disk full' is the terminal's own tftp_close, not a storage or map bug

A tcpdump on the tap plus a live execution trace settle the 'Disk full or
allocation exceeded' abort: the TERMINAL sends TFTP error 3, from
tftp_close (0x3F5EC860), which reports an incomplete read (the last DATA
block read was a full 512-byte block) using its fixed text for code 3. It
is not a capacity or disk condition.

The font buffers (path/index read buffer bit 0x9316D0, fos parser stream
buffer 0x227FD0, parsed-font cache 0x227F50) are all in main RAM
(0x00000000-0x01FFFFFF), mapped and writable; the writes land in RAM dumps.
No second map overlap exists, so the NVR fix did not cause this. vt1200.cpp
is unchanged and every baseline is intact.

New: fonttrace.lua, fontprobe.lua, two evidence pcaps, FONT_STORAGE_REPORT.md.
This commit is contained in:
arti committed 2026-10-16 22:03:16 +03:00
1 parent 1051bf2940
commit 7efb4880f0
6 files changed
+311

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
# The TFTP font "Disk full" abort — who sends it, where the font goes, why it is spurious
Date: 2026-10-16. Firmware `vt1200_v22.bin` md5
`b46a3e442ccb86134d2356bd70d22fd2` (**unmodified**). **No MAME-tree change, and no
change to `harness/mame/vt1200.cpp` at all.** This report answers the two
questions in the brief with a live `tcpdump` and a live execution trace, and
records why the answer is *not* a second map overlap.
## Verdict
1. **The terminal sends the TFTP error 3, not the server.** The capture shows the
client emitting the ERROR packet after the server's DATA packets. The
server's only error path is "File not found" (code 1); `harness/serve.py`
never sends code 3.
2. **The error is not a storage condition.** It is the firmware's own
`tftp_close` routine reporting **"the connection was closed before the read
reached a short (final) block"** using its fixed text for error code 3,
`Disk full or allocation exceeded`. Nothing is full.
3. **The font storage region is plain main RAM, correctly mapped.** The receive
buffers and the parsed-font cache all sit inside the driver's RAM mapping
(`bit 0x00000000`–`0x01FFFFFF`); the writes land (verified in RAM dumps).
**There is no second map overlap.** The NVR fix is not the cause.
4. A **valid** font transfer completes and is parsed (verified). A font the
parser **rejects or abandons part-way** — such as the crafted 25 KB font —
reads only the first 1024 bytes and then closes, which is what produces the
spurious "Disk full".
## 1. Who sends the error — capture
`harness/mame/font_tftp_real.pcap` (the owner's exact `harness/serve.py`, on the
isolated tap; the server also has the same log line the owner sees):
```
192.168.99.2.1025 > 192.168.99.1.69: TFTP, length 11, RRQ "69" octet
192.168.99.1.69 > 192.168.99.2.1025: TFTP, length 516, DATA block 1
192.168.99.2.1025 > 192.168.99.1.69: TFTP, length 4, ACK block 1
192.168.99.1.69 > 192.168.99.2.1025: TFTP, length 516, DATA block 2
192.168.99.2.1025 > 192.168.99.1.69: TFTP, length 36, ERROR ENOSPACE "Disk full or allocation exceeded"
```
The last packet is **from `192.168.99.2` (the terminal) to the server**. The
server's `_send_file()` receives it and prints
`tftp: client aborted transfer: '\x00\x03Disk full or allocation exceeded'`.
So the earlier reading of the log's wording was right and the "the terminal
prints a received error" reading was wrong: the terminal **generates and sends**
it. (`harness/mame/font_tftp_crafted.pcap` shows the same for the crafted font.)
## 2. The firmware path that sends it
The string `Disk full or allocation exceeded` is the terminal's fixed message
for **TFTP error code 3** (ROM file offset `0x7E225`, bit `0x3F5F1128`); the
per-code error table is built by `L3F5EF610` (`0x3F5EF610`, the send-error
routine). Code 3 is emitted from exactly one firmware site on this path:
```
tftp_close L3F5EC860 (bit 0x3F5EC860)
3f5ec8a0: move @0x1cb940, a11, 1 ; last-block flag
3f5ec8d0: jreq L3F5ECD10 ; flag == 0 -> send error 3
... build and send the ACK instead ...
L3F5ECD10:
3f5ecd10: move a14, -*sp, 1
3f5ecd20: movk 3, a9 ; TFTP error code 3
3f5ecd30: move a9, *a14+, 1
3f5ecd40: move *a13(0xffe0), *a14+, 1
3f5ecd60: calla L3F5EF610 ; tftp_send_error(...)
```
`@0x1cb940` (the "last block was short" flag) is set inside `tftp_read`
(`0x3F5ED230`):
```
3f5ef000: cmpi 0x200, a11 ; payload == 512 (a full block) ?
3f5ef020: movk 1, a9
3f5ef030: jrne L3F5EF050
3f5ef040: sub a9, a9 ; full block -> 0 (more may follow)
3f5ef050: move a9, @0x1cb940, 1 ; short block -> 1 (this was the end)
```
So the rule is mechanical: **if the connection is closed and the last DATA block
read was a full 512-byte block, `tftp_close` sends ERROR 3.** The separate
"bufsize too small" check in `tftp_read` (`0x3F5EED30`–`0x3F5EEDA0`) never fired
in any run — the payload was always ≤ the capacity.
### What stops the read early
Live trace (`harness/mame/fonttrace.lua`, `debug.log`), no-env + skip level 1:
* the font-path object `69` (served as `system-10x20.pcf`, 25 860 B):
`tftp_read` reads **one** 512-byte block, returns 512, then `tftp_close`
fires and takes the `CLOSE->ERROR3` branch;
* the same with `baseline.pcf` (680 B) served for `69`: identical — one full
block, then close/error;
* a **400-byte** object (single short block, `<512`): DATA1 → ACK1, **no error**;
* the 680-byte `baseline.pcf` read as a **font** (the terminal derives a name
from the path object and re-fetches): DATA1 → ACK1 → DATA2 → ACK2, **no error**
— the read reached the short final block;
* the crafted `evil-real.pcf` (25 860 B) read as a **font**: DATA1 → ACK1 →
DATA2 → ACK2 → server sends DATA3, the terminal never ACKs it, and ~4 s later
the terminal sends ERROR 3 — the *parser* consumed the first 1024 bytes and
abandoned the font before EOF.
So the abort tracks **"how much of the file the reader asked for"**, not any
size/capacity limit of a storage device. A reader that stops on a full block
(the path/index reader, one block; the crafted font's parser, two blocks) trips
`tftp_close`; a reader that reads to EOF does not.
## 3. Where the terminal stores a fetched font — and its mapping
Read from the live CPU and from RAM dumps (`VT1200_DUMP_RAM_FRAMES`); a RAM-dump
byte offset is `bit address >> 3` (verified against the NVR settings copy:
the font path `192.168.99.1:69` sits at byte `0x345EB`, which is the settings
copy base `bit 0x1A0020` plus offset `0x5E7`).
| what | bit address | byte | mapping |
|---|---|---|---|
| font-path / index read buffer (`tftp_read` arg 2) | `0x9316D0` | `0x1262DA` | main RAM (`0x00000000`–`0x01FFFFFF`) |
| `fos` PCF parser stream buffer | `0x227FD0` | `0x44FFA` | main RAM |
| parsed-font cache record array | `0x227F50` | `0x44FEA` | main RAM |
| cache count / capacity | `0x227FB0` / `0x227FC0` | `0x44FF6` / `0x44FF8` | main RAM |
| settings copy (font path lives here) | `0x1A0020` | `0x34004` | main RAM |
All of these are inside the driver's RAM region and are **writable**: the RAM
dump taken during a fetch contains the received file bytes at the read buffer's
address, and the settings copy contains the NVR font path. The only other
addresses the font/TFTP code touches are ROM. (A scan of every absolute operand
in `harness/dis/fontregion-v22.txt` finds 22 main-RAM addresses, 104 ROM
addresses, and nothing in the VRAM range, the board windows, or unmapped space.)
### The "second map overlap" lead: checked, none exists
The NVR fix repaired `0x6000000` sitting inside the video-memory mapping
(`0x04000000`–`0x1FFFFFFF`). I looked for the same shape in the font path and it
is not there:
* every font/TFTP buffer is in RAM (`bit < 0x02000000`), far below the VRAM base
`0x04000000`, and the writes demonstrably land;
* the only region the font path reads that could have been swallowed is the NVR
itself, and that is now correctly mapped — the font path was read out of it
intact.
### The NVRAM bisect
Reverting the NVR mapping cannot change this, so I did not spend a rebuild on a
confounded experiment (with the NVR unmapped the font path reads garbage, so the
terminal would not even request `69`, and the comparison would be meaningless).
The direct evidence is stronger: the abort is driven by the **served content**
(the 400-byte object completes; the 680-byte object aborts; the same 680-byte
object read as a font completes) and by the **parser's early exit** on the
crafted font — neither of which touches `0x6000000`–`0x6007FFF`. The NVR fix did
not introduce the abort; it is upstream of it, because it is what lets the font
path be read at all.
## 4. Does a font fetch now complete? Does the terminal parse it?
* **Yes for a font the parser accepts.** Serving `baseline.pcf` as the font name
the terminal derives, the transfer reads to the short final block and
completes with no ERROR, and the parser runs: the font's internal name
`vt1200test` appears in RAM at byte `0x1316`, and the font-cache count moves.
* **No for the crafted 25 KB font, and the reason is the parser, not storage.**
`evil-real.pcf` reads DATA1/DATA2 (1024 bytes) and the parser then abandons it;
the inevitable `tftp_close` then reports ERROR 3. Any font the parser bails on
part-way will produce the same spurious "Disk full" — including, in the
owner's log, the `69` path object when `serve.py` returns a 25 KB font for it.
So the "Disk full" does stop *close of connection*, but it is **downstream of
the parser's decision**, not a reason the font "cannot be stored". Fixing the
message would not make the crafted parse succeed; the parse already stops on its
own at 1024 bytes.
## 5. Baselines and configurations
`vt1200.cpp` was never changed, so every baseline is intact by construction; two
were re-run on the shipped binary to confirm:
```
no env, progress.lua 200 frames : PC=3FDCE400 samples=198 (3 distinct PCs)
VT1200_IO_PATH_STUB=1, 200 fr : PC=3FDCE400 samples=53 (75 distinct PCs)
ROM md5 : b46a3e442ccb86134d2356bd70d22fd2
```
`VT1200_SKIP_LEVEL1=1` (OS + Terminal Manager) and `VT1200_MOUSE=1` are
untouched (no code change).
## 6. What I refused to fake
* No ROM patch; the ROM stays byte-identical.
* No change to `harness/serve.py` (another worker's file) and no invented font
capacity or patched firmware check.
* No "make the error go away" shortcut: the error is reported as the terminal's
own `tftp_close` behaviour, with the packet capture and the code path that
prove it.
## 7. Reproduction
The tests ran in an isolated network namespace so they do not disturb the
owner's `tap-mess-1000-0` (a second MAME cannot open it — `TUNSETIFF` returns
EBUSY; verified). Scratch lives in `harness/mame/fontwork/` (untracked).
```sh
harness/mame/fontwork/netns_up.sh # private tap-mess-0-0 in netns vt1test
harness/mame/fontwork/run_trace_serve.sh 25 # owner's serve.py + tcpdump + fonttrace.lua
harness/mame/fontwork/run_trace5.sh 40 # crafted 25 KB font via the font reader
harness/mame/fontwork/run_custom.sh reference/fonts/baseline.pcf 30
harness/mame/fontwork/run_custom.sh harness/mame/fontwork/small400.pcf 25
```
New instrumentation in this directory:
* `harness/mame/fonttrace.lua` — prints the control-flow milestones of one
fetch (`tftp_read`, `tftp_open`, `tftp_close`, the opcode/block checks, the
ACK/error branch) with registers, to `debug.log`.
* `harness/mame/fontprobe.lua` — prints the `bufsize`/`payload` values at the
capacity check (`0x3F5EED50`) and at the error path.
+1
View File
@@ -70,6 +70,7 @@ Reports in this directory:
| `LANCE_REPORT.md` | the LANCE Ethernet model: the register file, the 8-byte address ROM and its checksum, the byte↔bit DMA, and the remaining loopback/CRC blocker |
| `LANCE_LOOPBACK_REPORT.md` | `C — LANCE TEST`: the TX buffer **does** carry a correct FCS in the passing `DTCR=1` case and deliberately carries **none** in the failing one; the residual mismatch is MAME never setting `RMD1_FRAM` |
| `NVRAM_REPORT.md` | the battery-backed NVR (bit `0x6000000`): previously unmodelled (its address was swallowed by the video-memory mapping), now a persisted `nvram_device`; the font path lives there, the font data does not |
| `FONT_STORAGE_REPORT.md` | the TFTP font "Disk full" abort: a capture proves the **terminal** sends TFTP error 3, and it is the firmware's `tftp_close` (`0x3F5EC860`) reporting an incomplete read, not a storage condition; the font buffers are in correctly-mapped RAM, so there is no second map overlap |
| `RENDER_REPORT.md` | the active-pixel scanline renderer, and the geometry it derived |
| `DUART_REPORT.md` | the DUARTs: wiring, the ISR-driven transmit, the datasheet-verified model edits |
| `BRINGUP_LOG.md` | the whole bring-up narrative, **including withdrawn claims** — read it sceptically |
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
-- fontprobe.lua -- live values for the TFTP font-read capacity check.
--
-- Breaks inside the firmware's tftp_read (0x3f5ed230) at the point where the
-- per-datagram payload (a9) is compared with the buffer capacity (a11), and at
-- the error path. Prints the frame, PC, a9 (payload), a11 (capacity) and the
-- accumulated total (dword at a13+8) so the "disk full" verdict can be read
-- directly rather than inferred.
--
-- FP_FRAMES=<n> budget (default 20000)
-- <mame> vt1200 ... -debug -debugger none -debuglog \
-- -autoboot_script harness/mame/fontprobe.lua -autoboot_delay 0
local machine = manager.machine
local dbg = machine.debugger
local cpu = machine.devices[':maincpu']
if not dbg then
print("ERROR: debugger not enabled; pass -debug")
machine:exit()
return
end
FRAME = 0
-- tftp_read entry: dump the three arguments from the callee frame (a13-12/-8/-4)
dbg:command('bpset 3f5ed290,,{ printf "TFTP-ARGS fr=%d arg1(len)=%08X arg2(buf)=%08X arg3(sock)=%08X\\n", FRAME, d@(a13-12), d@(a13-8), d@(a13-4) ; g }')
-- capacity check, just after `cmp a11,a9` (a9 = payload, a11 = capacity)
dbg:command('bpset 3f5eed50,,{ printf "CHK fr=%d pc=%08X payload(a9)=%08X capacity(a11)=%08X a13=%08X [%08X %08X %08X | %08X %08X %08X]\\n", FRAME, PC, a9, a11, a13, d@(a13-12), d@(a13-8), d@(a13-4), d@(a13), d@(a13+4), d@(a13+8) ; g }')
-- error path just before the message is formatted (a9 = payload first arg, a11 = capacity second)
dbg:command('bpset 3f5eeda0,,{ printf "BUFSIZE-ERR fr=%d a9=%08X a11=%08X\\n", FRAME, a9, a11 ; g }')
-- ERROR code 3 being sent
dbg:command('bpset 3f5eee50,,{ printf "SEND-ERROR-3 fr=%d\\n", FRAME ; g }')
-- tftp_read entry, count calls
dbg:command('bpset 3f5ed230,,{ printf "TFTP-READ-ENTRY fr=%d a14=%08X\\n", FRAME, a14 ; g }')
-- received an ERROR packet from the peer
dbg:command('bpset 3f5edb30,,{ printf "GOT-ERROR-PKT fr=%d\\n", FRAME ; g }')
local maxframes = tonumber(os.getenv("FP_FRAMES") or "20000")
emu.register_frame_done(function()
FRAME = FRAME + 1
if FRAME % 2000 == 0 then
print(string.format("frame %d PC=%08X", FRAME, cpu.state['PC'].value))
end
if FRAME >= maxframes then
print("done (" .. FRAME .. " frames)")
machine:exit()
end
end, "vt1200-fp")
dbg.visible_cpu = cpu
dbg.execution_state = 'run'
+46
View File
@@ -0,0 +1,46 @@
-- fonttrace.lua -- step-by-step trace of one TFTP font fetch.
--
-- Prints the control-flow milestones inside the firmware's TFTP client
-- (tftp_read 0x3f5ed230, tftp_open 0x3f5ebc40, tftp_close 0x3f5ec860) with the
-- key registers, so the reason a transfer is abandoned is read off the run
-- rather than inferred from the disassembly.
--
-- FT_FRAMES=<n> budget (default 20000)
local machine = manager.machine
local dbg = machine.debugger
local cpu = machine.devices[':maincpu']
if not dbg then
print("ERROR: debugger not enabled; pass -debug")
machine:exit()
return
end
FRAME = 0
dbg:command('bpset 3f5ed230,,{ printf "TFTP-ENTRY fr=%d a14=%08X\\n", FRAME, a14 ; g }')
dbg:command('bpset 3f5ed3f0,,{ printf " EXPECTED->%d fr=%d\\n", a11, FRAME ; g }')
dbg:command('bpset 3f5ed690,,{ printf " RECV ret=%08X fr=%d\\n", a8, FRAME ; g }')
dbg:command('bpset 3f5edb20,,{ printf " OPCODE=%04X fr=%d\\n", a11, FRAME ; g }')
dbg:command('bpset 3f5ee3d0,,{ printf " BLKREAD=%04X fr=%d\\n", a11, FRAME ; g }')
dbg:command('bpset 3f5ee420,,{ printf " BLKCHK blk=%04X exp=%08X fr=%d\\n", a11, a9, FRAME ; g }')
dbg:command('bpset 3f5ee720,,{ printf " ->ACK/send path fr=%d\\n", FRAME ; g }')
dbg:command('bpset 3f5eeae0,,{ printf " SEND-RET a8=%08X a11=%08X fr=%d\\n", a8, a11, FRAME ; g }')
dbg:command('bpset 3f5eecf0,,{ printf " COPY reclen=%08X fr=%d\\n", a9, FRAME ; g }')
dbg:command('bpset 3f5ef090,,{ printf " READ-RET a8=%08X fr=%d\\n", a8, FRAME ; g }')
dbg:command('bpset 3f5ec750,,{ printf "OPEN-OK fr=%d\\n", FRAME ; g }')
dbg:command('bpset 3f5ec860,,{ printf "CLOSE fr=%d\\n", FRAME ; g }')
dbg:command('bpset 3f5ecd10,,{ printf " CLOSE->ERROR3 fr=%d\\n", FRAME ; g }')
local maxframes = tonumber(os.getenv("FT_FRAMES") or "20000")
emu.register_frame_done(function()
FRAME = FRAME + 1
if FRAME >= maxframes then
print("done (" .. FRAME .. " frames)")
machine:exit()
end
end, "vt1200-ft")
dbg.visible_cpu = cpu
dbg.execution_state = 'run'