README: rewrite as the project front page -- status, findings, tidbits

The old README still described the repo as ROM images plus a Ghidra
setup. The project now spans the MAME harness, the real terminal, the
COFF/SDK toolchain and the networking reverse engineering, so rewrite it
as the entry point.

New top-level content:
- a dated status table (ROM, Ghidra, MAME, real unit, our code,
  networking, LAT, font path), with the open items named as open;
- what the firmware actually contains (MIT X11 R4, BSD TCP/IP via
  386BSD, BIND, telnet, TI RTS, fonts);
- the address model table, corrected against harness/mame/vt1200.cpp
  (RAM 0..0x1FFFFFF; DUART bit 0x06200000/0x06400000);
- how the faithful MAME run stops at level 0, why no documented device
  can pass it, and what the stub does and does not prove;
- the real-unit debug channel (TCP 771), the challenge, and a table of
  what is proven on hardware;
- the three routes for running our own code, and the heap model;
- the LANCE + BSD stack mapping, the RMD1_FRAM blocker and the claim
  deferred for real silicon;
- LAT and the idle-crash investigation, and the two font results;
- a curated "odd findings" list and a documentation index.

Factual corrections made while checking sources: the rdebug gate is a
SETF 2 field (the port was never closed), CSR0=0x1BB has ERR clear, the
pcfReadBitmaps overflow is not reachable on the TFTP path, and the
real-unit framebuffer is 1024x2048 line-interleaved (the "line doubled"
look was a partial read). The XCROSS 123k figure is the unmapped log,
not a VT1200_CAL proxy hit. ROM md5 unchanged.
This commit is contained in:
arti committed 2026-10-30 12:03:01 +02:00
1 parent 8ff16b5b73
commit a4ed68c0e4
1 file changed
+548 -156
+548 -156
View File
@@ -1,41 +1,96 @@
# DEC VT1200 X Terminal — ROM images
# DEC VT1200 X Terminal — ROM images, emulation, and what we found
The DEC VT1200 is a monochrome X Window System (X11) terminal (circa 1990).
It runs the X server, terminal manager, window manager, and VT emulator
entirely from ROM on a Texas Instruments TMS34010 CPU, with 2–4 MB of RAM and
no virtual memory.
The DEC VT1200 is a monochrome X Window System terminal (circa 1990). It runs
the X server, the terminal manager, the window manager and a VT emulator
**entirely from ROM** on a Texas Instruments **TMS34010** CPU, with 2–4 MB of
RAM and no virtual memory.
These are the firmware ROM dumps, split by firmware revision. The confirmed
memory map is in **[MEMORY_MAP.md](MEMORY_MAP.md)**.
This repository started as the firmware ROM dumps and has grown into a full
reverse-engineering and execution project: a MAME machine that runs the
firmware, a Ghidra database with the bit-address problems solved, a 1987 TI
toolchain that still builds working code, and a debug channel into a **real
terminal** over the LAN.
## Contents
**The ROM stays byte-identical throughout.** Every experiment patches at
runtime, if at all; the reference image is `vt1200_v22.bin`
(`md5 b46a3e442ccb86134d2356bd70d22fd2`).
---
## Status at a glance
_As of 2026-10-30._
| Area | State |
|---|---|
| **ROM images** | **Done.** v2.1 and v2.2 reconstructed from 14 EPROMs each, bank order and byte interleave proven, checksums verified. |
| **Disassembly / Ghidra** | **Done.** TMS34010 module rebuilt for Ghidra 12, bit-address pointer constants materialised as references (~40k in v2.2). |
| **MAME harness** | **Running.** Boots POST, fails level 0 honestly, parks in the diagnostics menu. With `VT1200_SKIP_LEVEL1=1` it boots the X server and reaches the **Terminal Manager**, with keyboard, mouse/tablet and LANCE. |
| **Real terminal** | **Opened.** The firmware's hidden debug TCP service on port **771** is reversed, its challenge is solved offline, and we can read, write, dump the ROM, load COFF images and run them. |
| **Our own code** | **Runs.** TI SDK assembly, a C program (`HELLO` in video RAM), and an X11 window — on the emulator *and* on the real unit. |
| **Networking RE** | **Mapped.** The LANCE driver and the BSD-derived TCP/IP stack are mapped function by function, with explicit firmware-specific entries where no counterpart exists. One claim still needs real silicon. |
| **LAT / the idle crash** | **Open.** The receive path is audited, the crash is narrowed to service announcements (not the data stream), and a soak procedure is ready for when the unit is back. |
| **Font path** | **Characterised, not exploitable as hoped.** The known `pcfReadBitmaps` overflow is real but is **not reachable** through the TFTP font loader. |
---
## What the machine actually runs
The image is not just DEC code. `reference/third-party-code.md` maps what is
inside `vt1200_v22.bin`:
| component | evidence |
|---|---|
| **MIT X11 R4** — server (`dix`, `mfb`/`cfb`) **and Xlib** | `mfb*`, `pcf*`, `snf*`, `bdf*`, `Xlib` strings; exact match to X11R4 `pcfrbitmaps.c` |
| **BSD 4.3/4.4 TCP/IP** | `$Header`/`@(#)` IDs for the socket/TCP/IP/UDP/mbuf set, imported at DEC by `craig`, 1989-10-05/06 |
| **BSD BIND resolver, telnet + TN3270, inetd, lpd lineage, libc pieces** | file IDs and code match |
| **TI C runtime V2.00** | `strlen`/`strcpy`/`strcmp` match `RTS.SRC` instruction for instruction |
| **Bitstream / Bigelow & Holmes / DEC Japan fonts** | copyright strings in the font data |
Source-ID strings (RCS `$Header:`, SCCS `@(#)`) name the original files and
dates. They are strong evidence of provenance, but they are file names, not
licence statements.
---
## Quick start
```sh
md5sum vt1200_v22.bin # b46a3e442ccb86134d2356bd70d22fd2
./reconstruct.py v2.1 # -> vt1200_v21.bin (address order)
./reconstruct.py v2.2 # -> vt1200_v22.bin
python3 -m unittest test_reconstruct test_ptr_constants -v
harness/mame/build.sh # build the MAME single-driver binary
harness/mame/run.sh # faithful: stops in the diagnostics menu
harness/mame/run.sh skip # VT1200_SKIP_LEVEL1=1: boot the OS to the GUI
harness/mame/run.sh stub # + VT1200_IO_PATH_STUB=1: get past level 0
harness/mame/run.sh pie # runtime-patch the failing checks; level 1 runs
harness/mame/run.sh net # skip + tablet + LANCE on a host tap
```
The confirmed memory map is in **[MEMORY_MAP.md](MEMORY_MAP.md)**; the harness
detail — devices, env knobs, regression baseline, working conventions — is in
**[harness/mame/README.md](harness/mame/README.md)**.
---
## ROM images
| Folder | Firmware | ROMs | Part numbers |
|--------|----------|------|--------------|
| `v2.1/` | V2.1 | 14 x 128 KiB | 23-210E9 .. 23-223E9 |
| `v2.2/` | V2.2 | 14 x 128 KiB | 23-274E9 .. 23-287E9 |
| `v2.1/` | V2.1 | 14 × 128 KiB | 23-210E9 .. 23-223E9 |
| `v2.2/` | V2.2 | 14 × 128 KiB | 23-274E9 .. 23-287E9 |
Each file is 131072 bytes (128 KiB) and is named by its DEC part number.
Each file is 131072 bytes and is named by its DEC part number. Dumped and
published by **9track.net** (Dave/9track collection):
<https://9track.net/roms/> (section "DEC VT1200", category *Terminal*),
files at <https://9track.net/roms/files/dec/vt1200/>. Fetched 2026-10-15.
The bitsavers terminal archive does not host VT1200 images (checked `/bits/`
and `/pdf/dec/terminal/`), so 9track.net is the canonical source.
## Source
Dumped and published by **9track.net** (Dave/9track collection):
- Index page: https://9track.net/roms/ (section "DEC VT1200", category *Terminal*)
- Files: https://9track.net/roms/files/dec/vt1200/
Fetched: 2026-10-15.
## Notes
- The bitsavers terminal document archive does not currently host VT1200 ROM
images (checked `/bits/` and `/pdf/dec/terminal/`), so 9track.net is the
canonical source for these dumps.
- The 9track table does not list the component/location for each ROM; only the
part-number-labeled filenames are given.
- Verify integrity with `sha256sum v2.1/* v2.2/*`.
## Firmware layout (reverse-engineered)
### Firmware layout (reverse-engineered)
The 14 EPROMs are **byte-interleaved 16-bit words**, not a flat byte stream.
Within each consecutive pair of part numbers, the **odd part number holds the
@@ -48,185 +103,522 @@ v2.2 pairs: (274,275) (276,277) (278,279) (280,281) (282,283) (284,285) (286,287
The seven 256 KiB banks are laid out so that `file offset == physical byte -
0x07E40000`: the **highest** part-number pair sits at the **lowest** address.
`reconstruct.py` writes that 1.75 MiB (0x1C0000) image by default; pass
`reconstruct.py` writes that 1.75 MiB (`0x1C0000`) image by default; pass
`--part-order` for the legacy ascending-part-number ordering.
```sh
./reconstruct.py v2.1 # -> vt1200_v21.bin (address order)
./reconstruct.py v2.2 # -> vt1200_v22.bin
python3 -m unittest test_reconstruct -v # layout regression tests
```
Evidence this interleave is correct: only it produces readable text, e.g.
Evidence the interleave is correct: only it produces readable text, e.g.
`-DEC-Terminal-Medium-R-Normal--14-100-100-100-C-80-ISO8859-1`, `WM XIO Error`,
`packet_size = %d exceeds MAXSIZE_RCVPKT`, and SCCS headers such as
`$Header: ctype.c,v 1.1 89/10/05 19:40:49 craig Exp $`. Getting the interleave
wrong shifts every string by one byte (e.g. `DEC` becomes `EDC`).
wrong shifts every string by one byte (`DEC` becomes `EDC`).
## Disassembly
---
The CPU is a TI **TMS34010**. The ROM is loaded at physical byte base
`0x07E40000` (bit `0x3F200000`); the reset vector (`0x3F200040`) points at the
power-on code at ROM+8 (`DINT; SETF 16,0,0; SETF 0,0,1; CLR A0; …`). Each
256 KiB bank begins with an 8-byte header `[checksum16][?][AAAA][5555]`.
## The address model (read this before any address)
MAME's bundled disassembler, which ships a `tms34010` backend:
The TMS34010 is **bit-addressed**. A register, PC or stored pointer `P` selects
a *bit*; the referred physical byte is `P >> 3`. The top two logical address
bits are not emitted, so `physical_bit == logical_bit & 0x3FFFFFFF`.
| region | physical byte | bit address |
|---|---|---|
| RAM (4 MiB) | `0x000000` | `0x00000000` |
| Video RAM | — | `0x04000000` |
| DUART2 (keyboard/mouse) | `0x00C40000` | `0x06200000` |
| DUART1 (host/printer) | `0x00C80000` | `0x06400000` |
| NVR (battery-backed settings) | — | `0x06000000` |
| Ethernet address ROM (8 bytes) | — | `0x06800000` |
| LANCE RDP / RAP | — | `0x05800000` / `0x05900000` |
| ROM (1.75 MiB) | `0x07E40000` | `0x3F200000` |
| ROM aliases (`0x7f…`, `0xbf…`, `0xff200000`–`0xffffffff`) | same ROM | top 2 bits undecoded |
| Reset vector | file `0x1BFFFC` | logical `0xFFFFFFE0` → `0x3F200040` |
Consequences that cost real time if forgotten:
* `read_word(0x3f200000)` returns ROM bytes 0..1 (physical `0x07e40000`).
* MAME models the program space as `memory_access<32, 1, 3>` — the address-map
unit is **one bit**, so a 16-bit word spans 16 address units. The TMS34020
uses `memory_access<32, 2, 3>` and is **not** interchangeable.
* Ghidra loads the image at byte base `0x07E40000`; **firmware bit address =
Ghidra address × 8**. `unidasm`/`dis34` print bit addresses; Ghidra prints
byte addresses.
* `currentProgram.getImageBase()` is not the ROM base — use the loaded block.
---
## Disassembly and Ghidra
MAME's bundled `unidasm` has a `tms34010` backend:
```sh
unidasm vt1200_v22.bin -arch tms34010 -basepc 0x7E40000 -count 40
```
Or `smart34010`'s `dis34` (note `-b` is a *bit*-address base):
`smart34010`'s `dis34` (note `-b` is a *bit*-address base; two encodings were
corrected and validated by round-trip through the 1987 assembler — see
`harness/sdk-roundtrip/`):
```sh
dis34 -a -l -b 3F200000 vt1200_v21.bin
```
`unidasm`/`dis34` print *bit* addresses (0x10 per 16-bit instruction), whereas
Ghidra uses byte addresses. `tools/vt1200_map.py` prints the vectors and
verifies the per-bank header checksums:
`tools/vt1200_map.py` prints the vectors and verifies the per-bank header
checksums:
```sh
python3 tools/vt1200_map.py vt1200_v22.bin
```
## Pointer constants (bit-address → byte-address)
The firmware stores **bit** addresses; a 32-bit value `P` refers to physical
byte `P >> 3`. A byte-addressed tool such as Ghidra cannot resolve those
constants directly. `tools/ptr_constants.py` scans an address-ordered image
for such constants and resolves their targets:
```sh
python3 tools/ptr_constants.py vt1200_v22.bin
python3 tools/ptr_constants.py vt1200_v22.bin --strings
python3 tools/ptr_constants.py vt1200_v22.bin --tsv pointers.tsv
python3 -m unittest test_ptr_constants -v
```
On v2.2 it finds ~44,700 constants, of which ~3,680 distinct targets are
strings (e.g. `0x004B08 <- 0x3F225840 'LAT: could not allocate XCB for …'`).
The Ghidra side is implemented in `ghidra_scripts/FixPtrConstants.java`: it
scans the loaded ROM and adds a `DATA` reference from each constant to the byte
it points at (`P >> 3`), leaving the bytes untouched. `AssertPtrFix.java` is a
headless regression check for it. Already applied to the project:
| Program | references created |
|---------|--------------------|
| `vt1200_v21.bin` | 37,187 |
| `vt1200_v22.bin` | 40,290 |
```sh
/opt/ghidra/support/analyzeHeadless ghidra vt1200 \
-process -noanalysis \
-scriptPath ghidra_scripts \
-postScript FixPtrConstants.java
```
## Ghidra
`tools/ptr_constants.py` finds bit-address pointer constants and resolves their
targets (`--strings` for string targets, `--tsv` for a table). On v2.2 it finds
~44,700 constants, of which ~3,680 distinct targets are strings, e.g.
`0x004B08 <- 0x3F225840 'LAT: could not allocate XCB for …'`.
Ghidra has no built-in TMS34010 language, so we install the community module
[`ZPaul2Fresh8/Ghidra_TMS34010`](https://github.com/ZPaul2Fresh8/Ghidra_TMS34010):
- `tools/ghidra-tms34010/` — vendored, patched module source
- `tools/ghidra-tms34010-local.patch` — all local changes vs. upstream
- `tools/install-ghidra-plugin.sh` — installs + compiles it into Ghidra
- `tools/install-ghidra-plugin.sh` — installs and compiles it (needs sudo)
```sh
tools/install-ghidra-plugin.sh # needs sudo; installs into /opt/ghidra
The patch fixes two things: the **Ghidra 12 pspec schema** (`space="rom"`
lowercase, `initialized="false"` on memory blocks) and the upstream **Midway
arcade board map**, whose `GFX`/`ROM` blocks collided with the loaded VT1200
image.
`ghidra/` is a ready-to-open project (`ghidra/vt1200.gpr`) with both images
imported at byte base `0x07E40000`. `ghidra_scripts/FixPtrConstants.java`
creates the bit-constant references (37,187 in v2.1, 40,290 in v2.2);
`AssertPtrFix.java` is the headless regression check. Caveat: the module's
pcode semantics are incomplete, so flow-following cannot expand far — the boot
bank is disassembled linearly as well.
---
## Running the firmware under MAME
A minimal machine (`harness/mame/vt1200.cpp`) loads the firmware into a
TMS34010 so it can be run under MAME's debugger: trace, breakpoints,
single-stepping, memory watch. It is a *visibility* tool, not an accurate
VT1200 emulation.
**A bare `harness/mame/run.sh` with no environment is the faithful configuration.** It
models video RAM, the board-window handshake, both DUARTs (real `SCN2681`
devices), the LK401 keyboard and a pointing device, the LANCE, the NVR and the
Ethernet address ROM. Two of those were not conveniences:
* the level-1 host-port transmit is **ISR-driven**, so a fake DUART cannot work;
* without a real DUART2, the board write-back latch answered a read of the
**write-only** IMR with its own value, so the OS serviced a phantom interrupt
that could never clear (and the GUI never appeared).
### Where a faithful run stops
A bare run passes POST and then **fails level 0's I/O data path test**, parking
in the interactive diagnostics menu:
```
VERSION 2.08 - SELECTED TEST TYPE:
```
The patch does two things:
That test wants **10 bytes** on DUART2 channel A, and nothing is attached. It
is not satisfiable by any documented device: the routine writes one byte
(`0xE1`, "disable auto-repeat", which has no reply), sets the channel to
**normal mode, not loopback**, and checks **only the count** — the received
byte is stored and never compared. The keyboard's reply is 4 bytes, the mouse's
4, the tablet's 5. A faithful LK401 would not help: the init sequence's
receiver reset **flushes the FIFO**. See `reference/level0-10byte-puzzle.md`.
1. **Ghidra 12 schema fixes** — `<data_space space="ROM">` must be lowercase
`rom` (matching the slaspec), and every `<memory_block>` needs
`initialized="false"` or a bit/byte-mapped address.
2. **Removes the Midway arcade content** — upstream shipped a Midway board map
(`VRAM`/`RAM`/`NVRAM`/`PALETTE`/`GFX`/`ROM` blocks plus `WATCHDOG`,
`DIPSWITCHES`, `INPUT_n`, `midtunit_*` and a `rom:0xFFFFC` trap table) that
does not describe the VT1200 and whose `GFX`/`ROM` blocks collided with the
loaded image. The cleaned `pspec` keeps only the CPU definition.
It used to "pass" by accident, because the write-back latch returned `0x0099`
from the status register and bit 0 is `RXRDY`, feeding the drain loop ten
phantom bytes. Making DUART2 real moved the regression baseline — that looks
like a regression and is the **removal of a false pass**.
### Project
### The one deliberate test-fixture stand-in
`ghidra/` is a ready-to-open Ghidra project (`ghidra/vt1200.gpr`) containing the
reconstructed **v2.1 and v2.2** images, imported with `tms34010:LE:32:default`
at byte base **`0x07E40000`** and disassembled from the reset vector plus a
linear pass over the boot bank.
`VT1200_IO_PATH_STUB=1` is **not a keyboard model**. It stands in for an
unknown fixture on the keyboard port: after the one `0xE1` write it feeds
**10 arbitrary (`0x00`) bytes** back on the same receive path, only while the
channel is polled. Nothing compares them; only the count matters. It is off by
default, and the faithful run still fails level 0 exactly as before.
| Program | Functions | Instructions |
|---------|-----------|--------------|
| `vt1200_v21.bin` | 8 | 61,401 |
| `vt1200_v22.bin` | 6 | 62,444 |
With it on, level 0 clears and level 1 reports `HOST PORT - 15` and
`PRINTER PORT - 16`, prints `TYPE 'GO' TO CONTINUE`, and **continues** — then
wedges in a DUART1 counter-ready **interrupt storm** from frame ~2839. It does
not boot the OS, and it does not park at `HOST PORT - 15`.
Helper scripts live in `ghidra_scripts/`:
### How far the OS gets
- `VerifyTMS34010.java` — locates the loaded block, follows flow from the reset
vector, then linearly disassembles the boot bank, and prints regions.
- `StatsTMS34010.java` — function/instruction counts.
- `FixPtrConstants.java` — converts bit-address pointer constants to references.
- `AssertPtrFix.java` — headless regression check for the above.
`VT1200_SKIP_LEVEL1=1` drives DUART1 **IP3** high, which the firmware takes as
a signal to jump to the OS cold-start and skip all 15 level-1 tests. This is a
*modelled board input*; the real machine reads IP3 low. A skipped run is
evidence about the **OS, graphics and crash behaviour**, and is **not** evidence
about the real terminal's POST.
Caveats:
With the skip, the OS boots a **running X11 server** and reaches the
**Terminal Manager**: the root-window weave renders, the cursor is handed to
the terminal's own mouse cursor, and the V2.2 box appears — title bar,
Messages, Ethernet/IP/LAT, a Memory bar, and "All Diagnostics Passed". The
keyboard and mouse drivers initialise and speak the real protocol; the LANCE is
initialised and the TM shows the real hardware address
`08-00-2B-16-4B-5A`. Evidence frames: `harness/mame/skip_frame3000.png`,
`duart2_default_tm_frame3000.png`, `tablet_cursor_*.png`.
- **`currentProgram.getImageBase()` is not the ROM base.** Use the *loaded*
block (`rom:07e40000`–`rom:07ffffff`); the helper script does this.
- The module's **pcode semantics are incomplete**, so flow-following cannot
expand far and the analyzers emit non-fatal `ArrayIndexOutOfBoundsException`s.
That is why the boot bank is also disassembled linearly.
- Ghidra 12 dropped Jython; its bundled Python scripts need PyGhidra, so the
helpers here are Java.
- Data cross-references are materialised by `FixPtrConstants.java` (the bit vs
byte address issue) — see the pointer-constants section above.
### Regression baseline
## Documentation
`progress.lua` at 200 frames, **no env**:
`docs/` holds the free DEC/TI documents gathered during this work. Each PDF
has a `.txt` sidecar with the extracted text (handy for `grep`).
```
PC=3FDCE400 samples=198 (3 distinct PCs)
```
| File | Source |
|------|--------|
| `EK-V1000-SV-002_service-guide.pdf` | filibeto.org/dec/hardware/EK-V1000-SV-002.pdf |
| `EK-V1000-UG-002_installing-VT1000.pdf` | filibeto.org/dec/hardware/EK-V1000-UG-002.pdf |
| `EK-V1200-UG-001_installing-VT1200.pdf` | filibeto.org/dec/hardware/EK-V1200-UG-001.pdf |
| `EK-V1000-RN-005_VT1000-v2.0-release-notes.pdf` | filibeto.org/dec/hardware/EK-V1000-RN-005.pdf |
| `EK-V1200-RN-001_VT1200-release-notes.pdf` | filibeto.org/dec/hardware/EK-V1200-RN-001.pdf |
| `EK-V1000-RC-003_installing-optional-memory.pdf` | filibeto.org/dec/hardware/EK-V1000-RC-003.pdf |
| `EK-V120I-IN-001_installing-DECimage-1200.pdf` | filibeto.org/dec/hardware/EK-V120I-IN-001.pdf |
| `EK-V1000-IP-001_illustrated-parts-breakdown.pdf` | filibeto.org/dec/hardware/EK-V1000-IP-001.pdf |
| `DTJ-1991-vol3num4_X-Window-Terminals.pdf` | Digital Technical Journal 3(4), Fall 1991, pp. 26–35 |
| `TMS34010-users-guide.pdf` | TI TMS34010 User's Guide |
| `ipb_pages/*.png` | page renders of the IPB (that scan has no text layer) |
It is a change detector, not a specification. It has moved twice, both
deliberately; `harness/mame/README.md` records why, including the
`VT1200_IO_PATH_STUB=1` value (`samples=53`, 75 PCs) as a separate baseline.
`reference/` holds the VT1000 v1.2 ROM dump (12 × 27C010) published by bitsavers
(`bitsavers.org/pdf/dec/terminal/vt1000/VT1000-v1.2.zip`) plus an earlier linear
`dis34` disassembly of v2.1 (gzipped).
### Deliberate divergences
Notably **no online document contains the memory map** — the missing artifact is
the `MP-02989-01` VT1000/VT1200 System Box Maintenance Print Set (fold-out
schematics). See MEMORY_MAP.md.
| divergence | default | what it does | restore the real machine |
|---|---|---|---|
| **VSXXX-AB tablet** | **on** | tablet on DUART2-B; absolute host-pointer reports | `VT1200_MOUSE=1` |
| `VT1200_SKIP_LEVEL1` | off | DUART1 IP3 high → skip all level-1 tests | leave unset |
| `VT1200_IO_PATH_STUB` | off | 10 bytes after the level-0 `0xE1` write | leave unset |
The full env-knob table (21 knobs, down from 33) is in
`harness/mame/README.md`.
---
## The real terminal
The unit is a stock v2.2 machine with MAC `08:00:2b:16:4b:5a`, static IP
`100.103.100.103`, reached from host `100.103.255.254`.
The big find is that the firmware runs a **remote debugger** it calls
`DEBUG TCP Port`, listening on **TCP 771** (`dwt/notes/rdebug-static.md`). Over
one connection a client can read and write the entire address space, call any
address as a function, capture console output, draw text, and make the terminal
open an outbound telnet connection.
The path is gated, and the static note's first gate turned out not to be one:
the settings word around `0xAECD0` reads `0x8070`, but the compare is a
`SETF 2` field at `0xAECD2` — **two bits**, and bits 2..3 are zero, so the
connection is **accepted**. The real gate is a 16-byte echo challenge at bit
`0x3F59FDC0` (`dwt_prf`). It is **solved offline**: `dwt/challenge.py` computes
the reply from the challenge alone, and two live sessions passed the `memcmp`.
That gave us, on real hardware:
| proven on the real unit | evidence |
|---|---|
| full ROM dump **byte-identical** to `vt1200_v22.bin` | `dwt/evidence/real-unit-rom.bin` |
| the read primitive (`peek`), screenshots assembled over it | `dwt/evidence/real-unit-canvas.png` |
| the write path: `malloc` → poke → COFF loader → `create_process` | `dwt/loader/evidence/hostload/` |
| the **first C program** on the terminal (`HELLO` into VRAM) | `harness/c-hello/` |
| a **real X11 window** created by an injected client | `dwt/gui/`, `dwt/notes/GUI_APPS.md` |
| a LAN TFTP fetch and load | `dwt/notes/TCPIP.md` |
| NVRAM and the LANCE address ROM read back and matching the model | `dwt/nvram_dump.py`, `dwt/rom_dump.py` |
The terminal's own MOP remote console also works (`dwt/mop_console.py`); `SHOW`
reports `Monitor Type VR319` and `Firmware Revision V2.2`.
### Real-unit display geometry (it is not what the emulator shows)
* The framebuffer is **1024×2048, line-interleaved, 256 KiB**: the image sits on
**even rows at 1:1**, odd rows carry a mask. The visible raster is
**1280×1024**, with the 1024-wide canvas at x=192.
* A 128 KiB read holds only the **top 512 image lines**; doubling that is what
produced the early "line doubled" and "missing bottom" pictures. The full
256 KiB read plus `dwt/screenshot.py --field even --stretch 1280x1024` matches
the glass.
* The Terminal Manager's `0x55`/`0xff` row pattern is a **non-displayed mask**,
not a background setting: setting the root background to 100% white changes
the root and leaves the TM pattern intact, while the screen looks solid white.
* The bottom `Ethernet Port Error - 24` box is the separate **AUI/transceiver
termination** test, not the address ROM test — that one passes.
MAME models a linear 1bpp framebuffer and therefore never shows the interleave;
that is a display-fetch difference, not a CPU difference.
---
## Running our own code
Three independent routes exist, in increasing order of realism:
1. **MAME debugger entry.** `harness/hello-world/` is an 82-byte GSPA payload
built by the 1987 TI SDK (`GSPA`/`GSPLNK` under DOSBox). `hello.lua` loads it
at bit `0x01800000`, arms a breakpoint on the diagnostics idle loop, and jumps
to it. It writes RAM markers and paints a VRAM block. This proves the
toolchain, **not** a delivery mechanism.
2. **COFF loader + `create_process`.** `dwt/loader/loader.asm` takes a linked
GSPLNK COFF already in RAM, places sections at their header addresses, zeroes
`.bss`, and runs the image as a first-class process via
`create_process(entry, "LOADER", 0x6000, 1, 0)`. The loader also **refuses a
target outside a heap free-list block** (status 8), which is a real check, not
a reservation.
3. **Real hardware.** `dwt/loader/hostload.py` drives the whole chain over
rdebug: `malloc` → poke the COFF → poke the loader → descriptor →
`create_process` → markers/PNG. `harness/c-hello/build.sh` builds a K&R C
program for the real unit (`ROW_BYTES 256`) and `build.sh emu` for MAME
(`128`). `dwt/gui/xhello.asm` does `XOpenDisplay` → `XCreateSimpleWindow` →
`XMapWindow` → `XSync` from a firmware-created process and leaves a real
window on the screen.
Address convention in the payload work: **Ghidra = `0x7E40000` + file offset**,
and **firmware bit address = Ghidra × 8**.
### The heap, and why payload addresses matter
Payloads are poked to fixed scratch addresses at bit `0x01800000` (byte
`0x00300000`) and above, at the top of the 4 MiB RAM. On a default boot that
region is **not allocated** — it lies inside the largest free block
(`byte 0x12B772..0x400000`, ~2.97 MB), so the OS may hand it out. There *is* a
real allocator and it is callable: `malloc` at bit `0x3F701230`, `free` at
`0x3F702440`, a bit-addressed first-fit over three arenas. The global free-byte
counter is itself **in bits** (bit `0x1E8BA0`); on the real unit it reported
**3,251,638 bytes** free. `dwt/heapmap.py` maps the free list read-only.
---
## Networking: the LANCE and the BSD stack
`harness/mame/NETWORK_REPORT.md` is the reference (4,000+ lines, all
measurement and disassembly).
**The driver.** The LANCE is an AM79C90 at two register windows (RAP bit
`0x05900000`, RDP bit `0x05800000`), with bus-master DMA where LANCE byte `B`
lands in main-RAM word `B >> 1`, and its interrupt on **INT1**. The driver does
not hard-code the registers in its normal paths; it keeps a device struct —
found in a live RAM dump at bit `0x23CBF0`. The **Ethernet address ROM** is
eight bytes, `08 00 2b 16 4b 5a c1 86`: the six-byte MAC plus two checksum bytes
the firmware validates.
**The stack.** It is 4.3/4.4BSD, imported at DEC in October 1989 and descended
from the **386BSD** line. Every function entry the report mentions now has a
mapping row: a quoted 4.4BSD counterpart where one exists (`leinit`, `leintr`,
`ipintr`, `ip_output`, `icmp_input`, `icmp_reflect`, `icmp_send`, `tcp_input`,
`tcp_dooptions`, `tcp_mss`, `udp_input`, `udp_output`, `rip_input`,
`in_arpinput`, `arpresolve`, `in_pcblookup`), otherwise an explicit
*firmware-specific* row saying why. The coverage check is mechanical, not a spot
check: 225 addressed sites, 143 initially absent from a mapping row, of which 41
sat in 26 distinct entry points — all 26 are now mapped. The honest caveat:
fourteen entries are marked firmware-specific because no counterpart was
**found**, which is not a proof that none exists.
**What a faithful model still owes.** `C — LANCE TEST` (the firmware's internal
loopback) fails on one bit: the test expects `RMD1 = 0x6B00`, MAME reports
`0x4B00` and never sets `RMD1_FRAM`. It is **not** an FCS loss — the passing
`DTCR=1` case carries a correct FCS, and the failing case is a *negative* test
that deliberately sends none. `M — ETHERNET ROM TEST` passes.
**One claim needs real silicon.** On the real unit, `INIT → IDON → STRT` works
(`CSR0 = 0x1B3`, then a stable `0x33`), but with a bare-`TDMD` kick the
transmitter never services the demand (`TDMD` stays set, the TX descriptor stays
owned at `TMD1 = 0x8338`). MAME cannot tell the two kicks apart, so this is
deferred until the hardware is back. Note the corrected decode: `CSR0 = 0x1BB`
has **`ERR` clear**; the anomaly is `INIT` still set while `IDON` is set.
### BOOTP
`harness/serve.py --bootp` answers the request the terminal broadcasts at
power-up, so its address no longer has to be typed in. Two details worth
keeping: `chaddr` carries **eight** bytes (MAC plus the two checksum bytes), and
the request is only visible at **L2** — the host IP stack never receives it from
the MAME tap, so the responder is an `AF_PACKET` socket, while the packet itself
stays an RFC 951 reply. It is off by default.
---
## LAT, and the crash we are chasing
`lat/` holds the normative DEC LAT specification (`AA-NL26A-TE`, June 1989),
the `latd` implementation and the Wireshark dissector, so the firmware's LAT
subsystem can be read against a spec and against working code.
`lat/notes/lat-receive-audit.md` audits the receive path. The demultiplexer
walks `nbr_slots` (wire byte 1) with **no length bound**, and slots are *acted
on*: Data_a is delivered, Start/Stop/Reject mutate session state. On a real
unit this produced an idle crash:
```
LEVEL0 ERROR -> Illegal OPCODE Occurrence - 28
007AA3F0 00200008 00000260 3F9B8EA0
```
Decoded as bit addresses, only `3F9B8EA0` is in ROM; the rest are RAM. The CPU
was executing out of RAM — the signature of a hijack via memory corruption.
Two candidate triggers were ranked: the LAT receive path, and the font parser.
Replaying the exact 27,529-byte session stream **did not reproduce it** (twice
over, ~2.4×). The remaining protocol difference is `latd`'s periodic **LAT
service announcements** (message type `0xA`, multicast), 91 of them in the
crashing session, none in the replay. The soak procedure in `NEXT_SESSION.md`
(20 s heartbeat, watch both screen and wire keepalives) is written up and
waiting on the unit.
---
## Fonts, and two results that correct earlier hopes
* The **TFTP "Disk full" abort is not storage**. A capture shows the terminal
itself sends TFTP error 3; it is the firmware's `tftp_read` buffer check
(`bufsize (%d) too small for %d bytes`) reporting an incomplete read. The font
buffers are in correctly-mapped RAM, so there is no second map overlap.
* The **`pcfReadBitmaps` overflow is real but unreachable on the TFTP path**.
The arithmetic is confirmed (`n` is a full 32-bit `pcfReadInt32`, no bound
check; `n=0x10000001` → `fosAlloc(0x40000004)`, `n=0x40000000` → alloc of 0),
but the TFTP font loader recognises the PCF magic and calls a **metadata
reader** that handles PROPERTIES/ACCELERATORS/METRICS/ENCODINGS and silently
**skips the BITMAPS table**. A crafted `num_chars` is never read there. See
`harness/mame/PCF_VALIDATION_REPORT.md`.
The earlier "disk full" symptom was traced to a client-side condition, which is
also why a server-side change cannot cause or cure it.
---
## Odd findings and other tidbits
* **Every 256 KiB ROM bank starts with an 8-byte header** `[checksum16][?][AAAA][5555]`.
`tools/vt1200_map.py` verifies them.
* **The reset vector is identical in v2.1 and v2.2**, and points at byte-identical
POST code (`0x3F200040` → `0x07E40008`).
* **The bank order is reversed**: the highest part numbers sit at the lowest
addresses. A natural-looking ascending order silently produces garbage.
* **The TM's stipple is a mask the display never shows.** The raw framebuffer is
striped; the glass is white because only the even field is fetched.
* **The debug service is documented in the firmware itself** — the string is
literally `DEBUG TCP Port`, next to the X11 port (`0x1770` = 6000) in the same
descriptor table.
* **The "closed" rdebug gate was a field-size mistake.** `0x8070` looked
non-zero, so the port looked shut; the compare is `SETF 2`, a **two-bit**
field, and bits 2..3 are zero. The port was always open.
* **The challenge has an ordering rule.** Send the opcode alone, read and answer
the challenge, *then* send the handler's arguments. Sent together, `dwt_prf`
eats the first 16 argument bytes as the answer and the handler reads shifted
fields.
* **The challenge is per-8-byte independent.** Changing bytes 0–7 never changes
output bytes 8–15 (0/64 single-bit trials), so the transform is blockwise.
* **The debug connection writes a message into the Terminal Manager**: each
connection adds a `Debug connection from IP host …` line to Messages. Five
lines in the first real-unit screenshot are five connections, not an error.
* **The firmware contains a full X11 R4 server *and* Xlib**, so our payloads are
X clients. `XOpenDisplay` from the rdebug dispatcher never completes; the same
code works from a process created by `create_process`, with the `Display *`
kept in a callee-saved register.
* **MAME's address-map precedence is last-registered-wins**, not first — which is
why one `VT1200_CAL` proxy over `0x00400000` saw zero hits while another over
`0x00000000` fired, and why the "unmapped" log's 123k accesses were not
unmapped at all (`XCROSS_REPORT.md`). The labels were right; the mental model
was wrong.
* **The level-1 VIDEO MEMORY failure is test code 22**, a 16-bit walking-bit
check plus a 32-bit pattern over `0x20000` units starting at bit
`0x04000000` (`CODE22_REPORT.md`).
* **The NVR was invisible because its address was swallowed** by the video-memory
mapping; it is at bit `0x6000000` and the font *path* lives there, not the
font data.
* **`scn2681.cpp` does not exist** in the MAME tree — the SCN2681 lives in
`mc68681.*`. Searching for the former walks the whole disk before failing.
* **A blank LAT window is not a crash.** `sleep 3600` printed nothing and an idle
session is legitimately blank; the connect handshake was identical to the
healthy session. Use an output-producing soak.
* **A capture filter can hide the suspect**: `ether host <mac>` does not capture
multicast. Use `ether proto 0x6004`.
* **The 1987 TI toolchain still works** under DOSBox, and `as34`/`ld34` were
ported to 64-bit Linux for assembler-level work. `dis34` had two encoding
bugs, both fixed and round-tripped through GSPA/GSPLNK
(`harness/sdk-roundtrip/`).
---
## Documentation index
| file | what it records |
|---|---|
| `MEMORY_MAP.md` | the confirmed ROM/RAM/vector map, with per-section confidence |
| `harness/mame/README.md` | harness devices, env knobs, divergences, regression baseline |
| `harness/mame/NETWORK_REPORT.md` | LANCE driver + BSD stack mapping, function by function |
| `harness/mame/GATE_CLEANUP_REPORT.md` | how the working config became the default |
| `harness/mame/DUART_REPORT.md` | DUART wiring, ISR-driven transmit, datasheet-verified edits |
| `harness/mame/RENDER_REPORT.md` | the active-pixel scanline renderer and its geometry |
| `harness/mame/INPUT_REPORT.md` | LK401 + VSXXX-AA, and the cursor evidence |
| `harness/mame/TABLET_REPORT.md` | the VSXXX-AB tablet and 5-byte absolute reports |
| `harness/mame/LANCE_REPORT.md` | the LANCE model, address ROM, byte↔bit DMA |
| `harness/mame/LANCE_LOOPBACK_REPORT.md` | why `C — LANCE TEST` fails (one bit) |
| `harness/mame/NVRAM_REPORT.md` | the battery-backed settings RAM |
| `harness/mame/LEVEL1_CONTINUE_REPORT.md` | what the stub run *actually* does |
| `harness/mame/CODE22_REPORT.md` | which test raises VIDEO MEMORY - 22 |
| `harness/mame/XCROSS_REPORT.md` | MAME address-map precedence, one instrument at a time |
| `harness/mame/FONT_STORAGE_REPORT.md` | the TFTP "Disk full" is a client-side read check |
| `harness/mame/PCF_VALIDATION_REPORT.md` | the overflow exists but the TFTP path never reaches it |
| `harness/mame/HELLO_WORLD_REPORT.md` | the SDK payload, debugger entry only |
| `harness/mame/FINDINGS.md` | font-parser and ROM-mapping analysis |
| `harness/mame/BRINGUP_LOG.md` | the bring-up narrative, **including withdrawn claims** |
| `harness/BOOTP_REPORT.md` | the BOOTP responder and why it is L2 |
| `dwt/notes/rdebug-static.md` | the debug TCP service, statically |
| `dwt/notes/CHALLENGE_REVERSE.md` | the 16-byte challenge, solved |
| `dwt/notes/REAL_UNIT.md` | first contact, geometry, corrections |
| `dwt/notes/GUI_APPS.md` | the X11 architecture and what a payload can do |
| `dwt/notes/LOADER.md` | the COFF loader and `create_process` |
| `dwt/notes/TCPIP.md` | the socket API and the TFTP fetch |
| `dwt/notes/HEAP.md` | the allocator and where payloads live |
| `dwt/notes/MESSAGEBOX.md` | the bottom diagnostics box and its status mask |
| `lat/notes/lat-receive-audit.md` | the LAT receive path, finding by finding |
| `reference/level0-10byte-puzzle.md` | why level 0 cannot be satisfied faithfully |
| `reference/level1-test-sequence.md` | the 15 level-1 tests and their codes |
| `reference/third-party-code.md` | what open-source code is in the ROM |
| `reference/tms34010-sdk.md` | the 1987 toolchain, buildable today |
| `EXPLOIT_NOTES.md` | the original code-execution audit and the crash decode |
| `NEXT_SESSION.md` | the LAT soak handoff (older; see the reports for later work) |
---
## Repository layout
```
vt1200/
├── v2.1/ v2.2/ # original 28 EPROM dumps
├── reconstruct.py # de-interleave a revision -> address-ordered image
├── test_reconstruct.py
├── MEMORY_MAP.md # the confirmed memory map
├── README.md
├── reconstruct.py # de-interleave -> address-ordered image
├── vt1200_v21.bin vt1200_v22.bin
├── docs/ # DEC/TI manuals (PDF + extracted .txt) + ipb_pages/
├── reference/ # VT1000 v1.2 ROM dump, earlier dis34 output
├── ghidra/ # Ghidra project (vt1200.gpr + .rep)
├── ghidra_scripts/ # Verify/Stats/FixPtrConstants/AssertPtrFix .java
├── MEMORY_MAP.md # the confirmed memory map
├── ghidra/ ghidra_scripts/ # Ghidra project + Verify/Stats/FixPtr/Assert
├── tools/
│ ├── vt1200_map.py # vectors + bank-header checksums
│ ├── ptr_constants.py # find/resolve bit-address constants
│ ├── smart34010/ # dis34 disassembler source
│ ├── ghidra-tms34010/ # patched processor module source
│ ├── ghidra-tms34010-local.patch
│ └── install-ghidra-plugin.sh
└── test_ptr_constants.py
│ ├── smart34010/ # dis34 (two encoding fixes)
│ ├── as34/ # TMS34010 binutils, ported to 64-bit Linux
│ ├── tms34010-sdk/ # the 1987 TI SDK (under DOSBox)
│ └── ghidra-tms34010/ # patched processor module + install script
├── harness/
│ ├── mame/ # the MAME driver, Lua probes, reports
│ ├── hello-world/ # 82-byte SDK payload (debugger entry)
│ ├── c-hello/ # first C program, runs on real hardware
│ ├── udpstream/ # UDP/TCP receive-throughput payloads
│ ├── sdk-roundtrip/ # dis34 fixes vs. the real assembler
│ └── serve.py # TFTP (+ optional BOOTP) server
├── dwt/ # real-unit debug client and notes
│ ├── challenge.py exploit.py # the challenge and the rdebug client
│ ├── loader/ # COFF loader + hostload over rdebug
│ ├── gui/ # injected X11 window
│ ├── tcpip/ # socket API payloads
│ └── notes/ # the real-unit investigation
├── lat/ # LAT spec, implementations, audit, probes
├── reference/ # datasheets, manuals, disassembly, boot video
├── docs/ # DEC/TI manuals (PDF + .txt), wiki drafts
└── images/ # real-unit photographs
```
---
## Ground rules
The project has five hard-won rules, written down in
`harness/mame/README.md`: search with an explicit path (never from `/`), stage
only your own paths (never `git add -A`), one sample is not evidence, any change
to MAME needs a quote and a second reader, and **do not fake it** — model what
the documentation supports, report the gap, and make any stand-in opt-in and
named for what it is.
That last rule is why the faithful configuration is the default, why
`VT1200_IO_PATH_STUB` is off and described as a test fixture rather than a
keyboard, and why `harness/mame/BRINGUP_LOG.md` keeps its withdrawn claims.