Files
vt1200/harness/mame
arti 02acabb7ef harness: real LK401 keyboard + VSXXX-AA mouse devices on DUART2
Add the terminal's actual input parts to the harness as device models on the
already-modelled SCN2681 at DUART2: a DEC LK401 keyboard (channel A, 4800 8N1)
and a DEC VSXXX-AA mouse (channel B, 4800 8O1), both driven by MAME's host
input and wired bit-by-bit to the DUART (rx_a_w/rx_b_w and a_tx_cb/b_tx_cb).

Route: the docs, not MAME's lk201. MAME's lk201 device executes the 68HC05
firmware 23-001s9-00.bin, which is not in our ROM set (the downloaded zip only
carries the 8051 23-004M2-00.bin, MAME's unused second region), so route 1 is
unusable without sourcing that ROM. The models follow PROTOCOL.md: the keyboard
answers FD with the 4-byte self-test and reports documented keycodes honouring
the division modes; the mouse answers T with its 4-byte self-test and streams
3-byte incremental reports.

One physical-layer detail was required: the SCN2681 receiver mis-samples the
first byte after a receiver reset unless the line is explicitly idle-high
before the start bit. Both models now hold TxD at mark when idle (which is
faithful anyway) -- isolated evidence: bit-banging 0x55 into rx_a_w gives
RHRA=D5 with no idle-high, RHRA=55 with four idle-high bits.

Verified (VT1200_SKIP_LEVEL1=1, input_inject.lua): the OS reads the keyboard
keycode 0xC2 (A) and ~900 bytes of VSXXX reports (dx=+16/+32); after the
self-test it switches the mouse to stream mode; the X11 cursor arrow is visible
at frame 4400 and has moved by frame 4600.

Nothing was tuned for the level-0 I/O data-path test: with no env the boot
still fails it honestly and the regression baseline is unchanged
(PC=3FDCE400 samples=198, 3 PCs). VT1200_IO_PATH_STUB=1 (53/75) and the skip
run (Terminal Manager at frame 3000) are also unchanged. ROM md5 unchanged.

See harness/mame/INPUT_REPORT.md.
2026-10-16 15:33:30 +03:00
..

VT1200 execution harness (MAME)

A minimal MAME machine that loads the VT1200 firmware into a TMS34010 CPU so we can run it under MAME's debugger: instruction trace, breakpoints, single-stepping, and direct memory watch. It is a visibility tool, not an accurate VT1200 emulation.

Everything in this directory is source-of-truth in this repo; the MAME source tree is disposable and lives outside the project (/home/arti/code/k-space/mame0289).

harness/mame/
├── vt1200.cpp        # the MAME driver (symlinked into the MAME tree)
├── build.sh          # clone-safe build wrapper
├── *.lua             # probe / capture / dump scripts (many; see the reports)
└── roms/vt1200/      # MAME ROM dir (symlink to ../../vt1200_v22.bin)

Reports in this directory:

file what it records
GATE_CLEANUP_REPORT.md promoting the working config to the default; retiring the settled env gates
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
ROMPATCH_REPORT.md runtime ROM patching (debug only — see the caveat)

Address model (the important part)

The TMS34010 is bit-addressed: a register/PC holds a bit address, and a pointer P refers to physical byte P >> 3. MAME models this with a program space of memory_access<32, 1, 3>, i.e. the address-map unit is 1 bit (a 16-bit word spans 16 address units). Source: tms34010.h — note the TMS34020 differs (memory_access<32, 2, 3>), so this is not interchangeable with the 34020. Therefore:

  • Address-map entries here are bit addresses, not byte addresses.
  • read_word(0x3f200000) returns ROM bytes 0..1 (physical byte 0x07e40000).
  • The reset vector is read from logical bit 0xffffffe0, which maps to the last 4 bytes of the image (file offset 0x1bfffc).

The driver maps:

region bit range bytes
RAM 0x00000000–0x001fffff 4 MiB at byte 0
ROM 0x3f200000–0x3fffffff 1.75 MiB at byte 0x07e40000
ROM aliases 0x7f…, 0xbf…, 0xff200000–0xffffffff same ROM (top 2 logical bits not decoded)

The 0xff… alias is what makes the reset-vector page resolve.

What is modelled by default

This changed — see GATE_CLEANUP_REPORT.md. The working configuration used to be a six-variable incantation; it is now the default, and a bare vt1200 vt1200 with no environment runs it.

  • Video RAM at bit 0x04000000 — required for the level-1 VIDEO MEMORY test.
  • The board-window handshake model (two-tick busy bit).
  • MAME's SCN2681 DUART at DUART1 (0x0640000, host/printer), with input port IP4–IP6 held high — that is how the driver reads the VR150 monitor switch (0x70). The level-1 host-port transmit is ISR-driven, so a real DUART is required here, not a convenience.
  • MAME's SCN2681 at DUART2 (0x0620000, keyboard/mouse). This one is a faithfulness fix, not a convenience — the real board has one. Without it the board write-back latch answers a read of DUART2's write-only IMR with its own written value, so the OS services a phantom interrupt that can never clear.
  • The real input devices on DUART2, driven by MAME's host input: a DEC LK401 keyboard (channel A, 4800 8N1) and a DEC VSXXX-AA mouse (channel B, 4800 8O1), both modelled from reference/keyboard-mouse/PROTOCOL.md and wired to the SCN2681 at the bit level (rx_a_w/rx_b_w and a_tx_cb/b_tx_cb). They answer FD/T because that is the protocol, not to satisfy a test. See INPUT_REPORT.md.
  • A mono palette (pens 0/1 = black/white) and an active-pixel scanline renderer that reads VRAM. See RENDER_REPORT.md.

A bare run gets through POST and then fails level 0's I/O data path test, so it lands in the interactive diagnostics menu (VERSION 2.08 - SELECTED TEST TYPE:) instead of reaching level 1.

That test wants 10 bytes on DUART2 channel A and nothing is attached, so this is an honest failure. It used to "pass" by accident: the write-back latch returned 0x0099 from DUART2's status register, and bit 0 of 0x99 is RXRDY — stuck asserted — so the firmware's drain loop counted ten phantom 0x8C bytes.

It is not satisfiable by any documented device — see reference/level0-10byte-puzzle.md. The routine writes exactly one byte (0xE1, "disable auto-repeat", which has no reply), sets MR2A=0x07 so the channel is in normal mode, not loopback (confirmed statically and from a live access log), and checks only the count: the received byte is stored and never compared, and there is no expected-value table. The keyboard's FD/power-up reply is 4 bytes, the mouse's T reply is 4, the tablet's 5 — none is 10. A faithful LK401 would not help either: the init sequence's receiver-reset flushes the FIFO.

The real terminal does pass this test, so the bytes exist on real hardware — they are simply not inferable from the ROM, and the only honest way to close the question is to observe a real keyboard port at power-up. No faithful responder can be derived from any document.

So instead there is an opt-in stand-in, VT1200_IO_PATH_STUB=1. Read what it is carefully, because it is easy to misread:

  • It is not a keyboard model and it is not any documented device. It stands in for an unknown test fixture on the keyboard port.
  • Its bytes are arbitrary (0x00). Nothing compares them: the routine stores the received byte and never reads it back, and there is no expected-value table, so only the count matters. Feeding a pattern would imply a protocol that does not exist.
  • It triggers on the exact thing the test does: the one 0xE1 write to DUART2-A THRA (handler reg 3, window 0x0620000, store at bit 3FDCE8B0), then feeds 10 bytes back on the same channel's receive path via the SCN2681's own rx_fifo_push(). It replies only while the channel is polled (IMR = 0x00, the level-0 condition), so it cannot inject into the interrupt-enabled OS phase or cause an interrupt there.
  • It is off by default. A bare run still fails level 0 exactly as before and says so. This is deliberate: the faithful configuration stays the default.

With the stub on the boot clears level 0 and reaches level 1, where the DUART tests fail: HOST PORT - 15, then PRINTER PORT - 16. It does not park at HOST PORT - 15. Each failure is reported, the board LED blinks once (finite), the firmware prints TYPE 'GO' TO CONTINUE (which nothing answers and which does not wait), and the runner continues. It then enables DUART1's counter-ready interrupt and wedges in a DUART1 interrupt storm — that is the faithful ceiling, not HOST PORT - 15. It does not produce a booted OS. See LEVEL1_CONTINUE_REPORT.md.

There is no infinite board-LED loop on this path. 3F208060/3F208650 are never executed here, and the boot loop's 3F204440 jane L3F208060 is never reached (the level-1 runner never returns). The blink that is seen is the finite L3F208660 called by the error handler. A level-1 failure is fatal only through L3FDF1010 → L3FDF1270, the genuinely infinite blink, which is entered when the status word *A13(0xFFA0) is nonzero; the DUART failures leave it zero.

How far the OS gets

With VT1200_SKIP_LEVEL1=1 (below) the OS boots to a running X11 server and on to the Terminal Manager: the root-window weave renders, the cursor is handed over 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".

Confirmed in the GUI and from frame dumps: skip_frame3000.png (the weave) and duart2_default_tm_frame3000.png (the Terminal Manager). The real terminal shows the same sequence — see reference/vt1200-boot-video/.

The OS's keyboard and mouse drivers initialise and speak the real protocol: it programs DUART2 (MR1A=0x13, MR2A=0x07, CSRA/CSRB=0x99, ACR=0x60, IMR=0x33) and sends FD plus LED/click/bell/auto-repeat to the keyboard and 'T' to the mouse. Input now works: the LK401 and VSXXX-AA device models answer those commands, the OS reads 01 00 00 00 / A0 02 00 00, switches the mouse to stream mode, and the X11 cursor moves with the mouse while keycodes reach the OS. See INPUT_REPORT.md (the cursor evidence is the input_cursor_*.png frames).

Not modelled: LANCE Ethernet. It does not block the X11 bring-up.

Getting past level 1

VT1200_SKIP_LEVEL1=1 drives DUART1's IP3 high, which the firmware takes as a signal to jump to the OS cold-start and skip all 15 level-1 tests. Note what this is: a modelled board input, and the real machine reads IP3 low. So a level-1-skipped run is evidence about the OS, graphics and crash behaviour, and is not evidence about the real terminal's POST.

The skip is deliberately not the default: the real machine runs level 1, so the faithful configuration stays the default and the switch is how we diverge.

Environment knobs (18; down from 33)

All opt-in, and all off by default — there is no longer a "working incantation" to remember, because everything the firmware needs to run is now the default.

Instrumentation: VT1200_DUARTLOG, VT1200_BOARD_LOG, VT1200_BOARD_MAP, VT1200_IRQLOG, VT1200_LOG_UNMAPPED, VT1200_DPYLOG (+_FRAMES), VT1200_DUMP_FRAME (+_FRAMES), VT1200_DUMP_RAM (+_FRAMES), VT1200_DUMP_VRAM (+_FRAMES), VT1200_DUMP_DIR, VT1200_NO_RENDER, VT1200_INPUT_LOG (per-byte keyboard/mouse TX/RX trace, with emulated time). Behaviour switches: VT1200_SKIP_LEVEL1, VT1200_IO_PATH_STUB (the level-0 test-fixture stand-in described above — not a keyboard model, off by default).

Regression baseline

progress.lua at 200 frames, no env:

PC=3FDCE400 samples=198 (3 distinct PCs)

This has moved twice, both deliberately:

  1. samples=199 (1 PC) → samples=53 (78 PCs): the working configuration became the default, so a bare run stopped wedging at the hardware-ready gate and began executing real POST work.
  2. samples=53 (78 PCs) → samples=198 (3 PCs): DUART2 became a real SCN2681. Level 0's I/O data path test now fails honestly instead of being satisfied by the write-back latch's phantom RXRDY, so the run parks in the diagnostics menu. That looks like a regression and is not one — it is the removal of a false pass.

The no-env value is unchanged by VT1200_IO_PATH_STUB — stub off is the default, so the samples=198 (3 PCs) line above still holds. The stub is a separate observed configuration:

# VT1200_IO_PATH_STUB=1, progress.lua at 200 frames
PC=3FDCE400 samples=53 (75 distinct PCs)

That is a genuine move, not a false pass: with the count-only level-0 test satisfied, the run leaves the level-0 wait and executes level-0/level-1 work. It is not parked at HOST PORT - 15: the console transcript continues (LEVEL1/2 TESTING **, LEVEL1/2 ERROR -> HOST PORT - 15, LEVEL1/2 ERROR -> PRINTER PORT - 16, TYPE 'GO' TO CONTINUE), and the run ends in a DUART1 counter-ready interrupt storm (from frame ~2839; PC in the DUART1 INT1 ISR, polling ISR reg 5 = 0x08). See LEVEL1_CONTINUE_REPORT.md. It is recorded as a different baseline because it is opt-in; the default must stay samples=198.

It is a change detector, not a specification. If it moves without you meaning it to, find out why.

Build

MAME source is required. A shallow 0.289 checkout is large and slow to fetch.

git clone --depth 1 --branch mame0289 \
    https://github.com/mamedev/mame.git /home/arti/code/k-space/mame0289

harness/mame/build.sh            # symlinks the driver in and runs make

build.sh runs make -j$JOBS SUBTARGET=vt1200 SOURCES=src/mame/dec/vt1200.cpp, which builds a single-driver binary /home/arti/code/k-space/mame0289/vt1200.

The MAME tree is large and the build is memory-hungry: build.sh defaults to -j8, not all cores. Override with JOBS=4 harness/mame/build.sh on a small/low-memory machine. Progress is written to harness/mame/build.log.

Run

Headless execution and inspection use MAME's Lua debugger API from -autoboot_script. (-debugscript only works when a debugger UI drives the console, so it is not usable headless.)

cd /home/arti/code/k-space/vt1200
M=/home/arti/code/k-space/mame0289/vt1200

# trace execution from the reset vector
$M vt1200 -rompath harness/mame/roms -video none -sound none -nothrottle \
   -debug -debugger none \
   -autoboot_script harness/mame/trace.lua -autoboot_delay 0

# demonstrate the pcfReadBitmaps n*4 overflow (OVF_N overrides n, decimal)
$M vt1200 -rompath harness/mame/roms -video none -sound none -nothrottle \
   -debug -debugger none -debuglog -seconds_to_run 5 \
   -autoboot_script harness/mame/overflow.lua -autoboot_delay 0

The Lua API exposes manager.machine.debugger, device.debug (step, go, bpset, wpset), CPU registers (cpu.state['A0'].value), memory (cpu.spaces['program']:read_u32/write_u32), and frame hooks.

Drive the keyboard and mouse (input_inject.lua)

The LK401/VSXXX-AA models read MAME's host input, so they can be driven from Lua for a headless acceptance run:

M=/home/arti/code/k-space/mame0289/vt1200
VT1200_SKIP_LEVEL1=1 \
VT1200_DUMP_FRAMES=4400,4600 VT1200_DUMP_DIR=/tmp \
$M vt1200 -rompath harness/mame/roms -video none -sound none -nothrottle \
   -debug -debugger none \
   -autoboot_script harness/mame/input_inject.lua -autoboot_delay 0

input_inject.lua walks the mouse from frame 4400 and taps A at frame 4450. input_probe.lua prints every device port/field tag. See INPUT_REPORT.md.

Trace from the reset vector (trace.lua)

Prints the reset PC (expect 3F200040), disassembles the power-on code to reset.dasm, and writes an instruction trace of the first two frames to vt1200-reset.trace.

Exercise the pcfReadBitmaps overflow (overflow.lua)

Instead of booting the parser we jump directly into its arithmetic block with a crafted in-memory PCF_CharInfo:

a9  = 0x00040000   ; fake pCS;    pCS->nChars at a9+0x270
a13 = 0x00060000   ; fake arg blk; n (from pcfReadInt32) at a13+0x20
write_u32(0x00040270, 0)        ; nChars == 0
write_u32(0x00060020, n)        ; n
pc = 3f984e70
break at 3f985060               ; just after 'sll 0x2,a11'

This is possible because the font parser's field moves use field 1, which SETF 0,0,1 leaves at 32 bits, so n is a full 32-bit pcfReadInt32 value. See FINDINGS.md for results and the rest of the analysis.