Chapter 10 — The TEC-1 Platform
The TEC-1 is a single-board Z80 computer designed in Australia in 1983. It has a six-digit seven-segment display, a 23-key hexadecimal keypad, a single-bit speaker, a small RAM expansion, and a bitbang serial port. The TEC-1 platform in Debug80 simulates all of this hardware in sufficient detail to run TEC-1 programs at timing-accurate speed.
The platform lives in src/platforms/tec1/.
Memory layout
0x0000–0x07FF ROM (2KB) — MON-1B monitor firmware
0x0800–0x0FFF RAM (2KB) — user code and data
The ROM range is protected against writes. The default entry point is 0x0000 (the monitor reset vector). The application start address (for warm restarts) is 0x0800.
The ROM
The MON-1B ROM is the original TEC-1 monitor firmware. It provides a hex entry interface, a memory scanner, simple I/O routines, and an interrupt handler. When the program counter is at the ROM entry and the user presses a key, the monitor processes it and drives the display accordingly.
The ROM source is resolved at launch: tec1Config.romHex points to a custom ROM file, or the bundled MON-1B HEX is used. The bundled ROM is at extension.extensionPath/roms/tec1/mon-1b/mon-1b.hex. The loader tries a .bin companion first (faster to load), falling back to Intel HEX parsing.
Platform state
Tec1State in src/platforms/tec1/runtime.ts holds all hardware state for a running TEC-1 session:
interface Tec1State {
digits: number[]; // Current 7-segment values for 6 digits
matrix: number[]; // LED matrix row values
digitLatch: number; // Port 0x01 write latch
segmentLatch: number; // Port 0x02 write latch
matrixLatch: number; // Port 0x06 write latch
speaker: boolean; // Speaker on/off state
speakerHz: number; // Last calculated speaker frequency
lcd: number[]; // 32-byte HD44780 buffer (16×2)
lcdAddr: number; // Current DDRAM address
cycleClock: CycleClock; // Cycle-accurate event scheduler
lastEdgeCycle: number | null;
silenceEventId: number | null;
keyValue: number; // Current key (0x7F = no key)
keyReleaseEventId: number | null;
nmiPending: boolean;
lastUpdateMs: number;
pendingUpdate: boolean;
clockHz: number;
speedMode: Tec1SpeedMode;
updateMs: number;
yieldMs: number;
}
This object is created fresh on each session launch and reset by Tec1Runtime.resetState(). It is also directly referenced by the custom DAP command handlers, which need to read key state and queue serial bytes.
I/O ports
The TEC-1’s Z80 uses eight I/O port addresses:
| Port | Dir | Purpose |
|---|---|---|
| 0x00 | IN | Keyboard scan; bit 7 = serial RX level |
| 0x01 | OUT | Digit select (bits 0–5), speaker (bit 7), serial TX (bit 6) |
| 0x02 | OUT | Segment pattern (bits 0–7, DP+ABCDEFG) |
| 0x03 | IN | Status: bit 6 = no key pressed (1 = idle), bit 7 = serial RX |
| 0x04 | OUT | HD44780 LCD command register |
| 0x05 | OUT | LED matrix strobe (triggers row update from latch) |
| 0x06 | OUT | LED matrix row data latch |
| 0x84 | OUT | HD44780 LCD data register |
Ports 0x04, 0x05, 0x06, and 0x84 are extensions added to the TEC-1 hardware for the LED matrix and LCD. Core TEC-1 programs only use ports 0x00–0x03.
The seven-segment display
The TEC-1 has six seven-segment digits. The monitor firmware drives them by multiplexing — rapidly cycling through each digit, asserting its segment pattern for a few microseconds before moving to the next.
The emulator captures this without attempting to simulate the phosphor persistence. Instead, it records each digit latch write and uses the latest value for display:
Port 0x01 write (digit latch): Bits 0–5 each select one of the six digits. The currently written segment pattern (from port 0x02) is applied to each selected digit:
digitLatch = value
for i in 0..5:
if digitLatch & (1 << i):
digits[i] = segmentLatch
Port 0x02 write (segment latch):
segmentLatch = value
The segment byte encodes the seven segments and decimal point: bits 0–6 correspond to segments A–G, bit 7 is the decimal point. A 1 bit illuminates the segment.
After any digit update, a UI refresh is queued via queueUpdate(). The update throttle (default 16ms / 60fps) prevents sending a DAP event on every single port write.
The keyboard
The TEC-1’s keyboard matrix returns a key code on port 0x00. No key returns 0x7F. The monitor polls port 0x03, bit 6 to detect a keypress (bit 6 = 0 means a key is down), then reads the key code from port 0x00.
Key input in the debugger comes through the debug80/tec1Key custom DAP request. applyKey(code) in the runtime:
- Sets
keyValueto the key code. - Sets
nmiPendingto true — the TEC-1 generates an NMI on keypress, which the monitor handles. - Schedules a key release after
TEC_KEY_HOLD_MS(30ms) via the cycle clock.
On release, keyValue returns to 0x7F.
The NMI vector is 0x0066. The tick function returns { nonMaskable: true } when nmiPending is set, causing the Z80 runtime to trigger an NMI at the end of the current instruction.
The speaker
The speaker is driven by bit 7 of port 0x01. When the program toggles this bit, the speaker toggles state. Sound is produced by square waves — the program writes 1, waits some cycles, writes 0, waits the same, repeating at the desired frequency.
The emulator does not produce audio. Instead, it measures the frequency of the square wave and reports it to the webview, which can display the frequency or render a visual representation.
When bit 7 transitions:
delta = currentCycle - lastEdgeCycle
speakerHz = clockHz / 2 / delta
lastEdgeCycle = currentCycle
The division by 2 accounts for a full cycle being two edges (one rising, one falling). After TEC_SILENCE_CYCLES (10,000) cycles with no edge transition, the speaker is silenced — speakerHz is set to 0 and the speaker state is cleared.
silenceSpeaker() is called on session disconnect to prevent leftover audio state.
Serial communication
The TEC-1’s serial interface is bit-banged on the same port as the display: bit 6 of port 0x01 is the TX line; bit 7 of port 0x00 is the RX line.
Transmit decoding
On every write to port 0x01, the emulator extracts bit 6 and passes it to a BitbangUartDecoder. The decoder watches for start bits and reconstructs bytes from the bit stream at 9600 baud.
Decoded bytes are delivered via the onSerialByte callback registered during runtime construction. The callback routes them to the extension host as debug80/tec1Serial events, which the webview can display in a terminal panel.
Receive injection
The debug80/tec1SerialInput custom DAP request and Tec1Runtime.queueSerial(bytes) schedule incoming bytes for delivery over the RX line. Bytes are queued and delivered bit-by-bit at cycle-accurate timing. The first queued byte is preceded by a dummy 0x00 byte — this primes the decoder on the program side, which often needs to read and discard a byte before the real data arrives.
The cycle timing for RX is calculated from the current clock speed:
cyclesPerBit = clockHz / 9600
leadCycles = 2 × cyclesPerBit // Initial offset before first bit
Each bit is scheduled independently via the cycle clock. Start bit, 8 data bits, 2 stop bits — each one fires at the appropriate cycle.
Speed modes
The TEC-1 platform runs in two speeds:
| Mode | Clock | Purpose |
|---|---|---|
'fast' |
4 MHz | Original TEC-1 hardware speed |
'slow' |
400 kHz | Slow motion for observing display and keyboard |
The debug80/tec1Speed custom DAP request triggers runtime.setSpeed(mode), which updates clockHz, recalculates the serial bit timing, and immediately sends a UI update. The execution loop reads clockHz via the runtime capabilities for throttling calculations.
The LCD
Some TEC-1 variants have a 16×2 HD44780-compatible LCD. The emulator models this with a 32-byte buffer and a standard HD44780 address map:
- Row 0: DDRAM addresses 0x80–0x8F → buffer bytes 0–15
- Row 1: DDRAM addresses 0xC0–0xCF → buffer bytes 16–31
| Port 0x04 handles commands: clear display (0x01), return home (0x02), and DDRAM address set (0x80 | addr). Port 0x84 handles data writes — writing a character code to the current DDRAM address, then auto-incrementing. |
LCD state is included in the Tec1UpdatePayload sent to the webview.
Custom DAP commands
The TEC-1 platform registers four commands in the PlatformRegistry during buildLaunchSession():
| Command | Action |
|---|---|
debug80/tec1Key |
Queue a key press (code in args) |
debug80/tec1Reset |
Reset CPU and platform state |
debug80/tec1Speed |
Switch between fast and slow clock |
debug80/tec1SerialInput |
Queue bytes for RX delivery |
These are registered by platformProvider.registerCommands() during the launch pipeline, after the platform provider is resolved but before the runtime is created. The handlers close over the platform runtime’s methods.
The update payload
After any significant hardware state change, the platform queues a UI update. The sendUpdate() function assembles a Tec1UpdatePayload and emits it as a debug80/tec1Update DAP event:
interface Tec1UpdatePayload {
digits: number[]; // 6 values, one per display digit
matrix: number[]; // 8 row values for the LED matrix
speaker: number; // 0 or 1
speedMode: 'fast' | 'slow';
lcd: number[]; // 32 character codes
speakerHz?: number;
}
The extension host receives this event and forwards it to the webview via postMessage. The webview renders the display, matrix, and speaker state from this snapshot. Because updates are throttled to ~60fps, even programs that write to the display every instruction produce smooth rendering.
The CycleClock
CycleClock is the timing engine shared by both TEC-1 and TEC-1G. It maintains a monotonic cycle counter and a priority queue of scheduled callbacks. advance(cycles) increments the counter and fires any callbacks whose target cycle has been reached.
The runtime calls cycleClock.advance(result.cycles) after each instruction step. This drives:
- Key release — the key hold timer fires after enough cycles have elapsed for
TEC_KEY_HOLD_MS - Speaker silence — the silence event fires after
TEC_SILENCE_CYCLEScycles without a speaker edge - Serial bit timing — each RX bit is scheduled at an exact cycle offset
scheduleAt(cycle, callback) fires once at the target cycle. scheduleIn(delta, callback) fires after delta cycles from now. cancel(id) removes a pending event. The IDs are stored in state fields (keyReleaseEventId, silenceEventId) so they can be cancelled when superseded — for example, a new keypress cancels any pending key release.
Shared utilities (tec-common)
The TEC-1 and TEC-1G platforms share a set of utilities in src/platforms/tec-common/:
updateDisplayDigits()— applies the digit and segment latches to the digits arraycalculateSpeakerFrequency()— computes Hz from cycle delta and clock speedcalculateKeyHoldCycles()— converts millisecond hold time to cycle countshouldUpdate()— checks whether the update throttle has elapsedcreateTecSerialDecoder()— constructs the bitbang UART decodermicrosecondsToClocks()/millisecondsToClocks()— unit conversion helpersTEC_SLOW_HZ,TEC_FAST_HZ,TEC_SILENCE_CYCLES,TEC_KEY_HOLD_MS— shared constants
These are used by both platforms, keeping the implementations consistent and reducing duplication.
Summary
-
The TEC-1 platform emulates the original 1983 hardware: 2KB ROM (MON-1B), 2KB RAM, six 7-segment digits, 23-key hexadecimal keypad, bitbang serial, and speaker.
-
I/O is port-mapped. Ports 0x00–0x03 are the core TEC-1 interface; ports 0x04–0x06 and 0x84 extend it with LCD and LED matrix support.
-
The display is multiplexed — the emulator records latch writes and applies them to the digits array without simulating phosphor timing.
-
Keyboard input arrives via
debug80/tec1Key. Each key setsnmiPending, which triggers an NMI through thetick()function, causing the monitor to process the keypress. -
The speaker is frequency-measured rather than audio-produced. The emulator calculates Hz from edge timing and silences after 10,000 cycles of inactivity.
-
Serial is bitbang at 9600 baud. TX is decoded from port 0x01 bit 6. RX is injected at cycle-accurate timing via the cycle clock.
-
The CycleClock drives all hardware timing: key release, speaker silence, serial bit delivery.
-
Updates are throttled to ~60fps. The
Tec1UpdatePayloadsnapshot is forwarded by the extension host to the webview on each tick.