Emulating VT102 Hardware in Perl - Part 2: Overview and Opcodes


Emulating VT102 Hardware in Perl - Part 2: Overview and Opcodes

This second part of the series gives an overview of the various VT1xx models and the hardware, and then starts explaining the emulator by diving into memory and CPU representation and opcodes.

Oh, the Variety

The original VT100 was a rather modular and expandable device. Unlike the VT52 predecessor, it was also a very versatile and programmable terminal - you could not only add expansion cards for different interface standards or more RAM, but also CPU cards, floppy interfaces and so on, and you could even add a PDP-8 or CP/M to it and use it as a mini/microcomputer.

This also made the VT100 rather bulky and expensive, which is why a variety of derived models have been created, mostly with fixed "expansion" cards already built-in, and no expansion slots.

Here is a quick overview of the main variants:


The original VT100 came with four 2KB firmware ROMs and 2KB character ROM (the character ROM is not mapped to the CPU address space), for a total of 10KB ROM, and 3KB of RAM, 2.3 of which are used for the screen buffer. It used an Intel 8080 CPU, a custom video processor, NVRAM with the weirdest interface you have ever seen (1400[sic] MNOS[sic] bits that are good for "10 years and one billion reads") and an UART chip (PUSART, Intel 8251A or compatible) for serial communications.

It had a CRT built-in, and even came with a keyboard! And you could put the ROMs into their sockets in any order, and it would still work!

Advanced Video Option for the VT100 (AVO)

The original VT100 was rather lean on memory - characters could not have attributes (such as underline or blink), and it could only display 14 rows in 132 character mode. The advanced video option added another whopping 3KB of RAM (1Kx8 bit and 4Kx4 bit), which fixes this.

It also added the ability for four extra 2KB ROMs.


Designed "for the price sensitive customer" (1982 street price might have been $1700 for the VT100, $1200 for the VT101 and $3300 for the VT125), this is a basic VT100 without any of the gizmos and no way to extend it. Or rather, it's more like a VT102 without AVO.


This is the most popular VT100 variant - it comes with the AVO and a serial printer port on board and has newer ROMs with fewer bugs and more features (such as inert/delete line commands). It's also what most "VT100 emulators" actually try to emulate. I think it also uses a 8085 CPU, which is very similar to the 8080, but has different interrupt handling.

On the negative side, the expandability of the original VT100 is lost in this model.


This is a VT100 with a Q-Bus backplane, so you could (in theory) use all your old PDP-11 peripherals with it (or if you wanted, an actual PDP-11).


This seems to be a VT100 with a special "waveform graphics" expansion, that allowed you to overlay one or two function plots over the text.


Another try at adding graphics, this is a VT100 model with a ReGIS graphics card, which was much more capable than the VT105.


This model is a VT102 and additional ROM firmware to allow local form editing.


Same as the VT131, except it is based on the VT100, not the VT102.


A VT100 model with a Z/80 expansion board, floppy drive, and CP/M.

To summarise the hardware, there is the original VT100 board, with various expansions that get their own model numbers, and the VT102 board, also with various variants. That means that there are VT100 and VT102 boards which are wired slightly differently, but all other variants more or less use one of these two boards.

Overview of the vt102 emulator

The vt102emulator script is less than 900 lines of Perl. The CPU emulator is the biggest component, at 180 lines (+ 20 lines of JIT compiler), followed by the video "simulator", at roughly 90 lines of code.

The rest deals with the NVRAM, serial line, keyboard mapping, setting up the terminal hardware and software, and so on. That means most of the codebase deals with annoying little details such as feeding serial data fast but without overloading the poor VT100 firmware, and so on.

Most of the expansion card hardware (such as the printer port) is only simulated, i.e., enough for the firmware to start and not complain.

The __DATA__section comes with the four VT100 ROM images, two VT102 ROM images and the VT131 ROM image.

Register state and Memory Layout

The VT100 memory layout looks like this:

0000-1fff 8KB (4x2) of firmware2000-2bff 3KB of RAM (firmware + screen RAM)2c00-2fff 1KB extra screen RAM (AVO)3000-3fff 2KB (4 bit per address) of character attribute RAM4000-7fff unused8000-9fff 8KB (4x2) optional expansion ROM on the AVOa000-bfff 8KB (1x8) optional expansion ROM on the AVOc000-ffff unused

This memory is represented by a simple Perl array called @M, all of which is writable - the emulator effectively simulates a system with 64KB RAM:

my @M = (0xff) x 65536; # main memory

When it starts, it reads the ROMs from the data section:

my $ROMS = do {binmode DATA;local $/;<DATA>};0x6801 == length $ROMS or die "corrupted rom image";

And depending on the mode, copies the ROM data into the emulator RAM:

# populate mem with rom contentsif ($VT102) { @M[0x0000 .. 0x1fff] = unpack "C*", substr $ROMS, 0x2000, 0x2000; @M[0x8000 .. 0x9fff] = unpack "C*", substr $ROMS, 0x4000, 0x2000; @M[0xa000 .. 0xa7ff] = unpack "C*", substr $ROMS, 0x6000, 0x0800 if $VT131;} else { @M[0x0000 .. 0x1fff] = unpack "C*", substr $ROMS, 0x0000, 0x2000;}

The 8085 CPU (the predecessor of and very similar to the famous 8086 CPU - if you rename registers and opcodes, the 8086 can execute 8085 code), needs a comparatively small amount of state:

############################################################################## 8085 CPU registers and I/O supportmy $RST = 0; # pending interrupts (external interrupt logic)my $INTMASK = 7; # 8085 half interrupt maskmy $INTPEND = 0; # 8085 half interrupts pending

The main difference between the 8080 CPU (VT100) and the 8085 CPU (VT102) is the interrupt handling. The 8080 CPU has three interrupt lines, for a total of 7 different interrupts ("interrupt 0" is invoked at power on, and is not a real interrupt). Well, actually, when the 8080 interrupt line is asserted, the 8080 CPU simply fetches single instruction from the data bus, which is usually one of the 8 RSTinstructions which jump to the relevant interrupt vector, but you can treat this ias if there were three physical interrupt lines. The 8085 has additional maskable interrupts, which I call "half interrupts". The reason for this will become clear soon.

In the VT100, there are three interrupt sources: the keyboard (1), the serial line (2) and the vertical retrace interrupt (4). The number in parentheses is the value of the interrupt line that they are wired to, which means they are effectively ORed together.

For example, a vertical retrace normally invokes handler #4, but if it happens together with a serial line interrupt, it will invoke handler #6 (4+2).

When the CPU receives an interrupt, it multiplies it's number by 8 and continues execution at that address. This explains the start of the VT100 ROM:

X0000: dilxi sp,X204ejmp X003bX0008: call X00fdeiretX0010: call X03cceiretX0018: call X03cccall X00fdeiretX0020: call X04cfretX0028: call X04cfret

X0000is the reset vector, which disables interrupts, initialises the stack pointer and jumps to the init routine.

X0008is interrupt #1 (keyboard input), which handles the keyboard, enables interrupts and returns. Similarly, X0010handles the serial line, enables interrupts, and returns.

X0018is where it gets interesting - interrupt #3 is invoked when both keyboard (1) and serial line (2) have some outstanding interrupt, and calls both service handlers.

The 8085 can also use this system, but has additional 5.5, 6.5and 7.5interrupt lines. These kind of work as if an interrupt of that number happened, i.e., it is multiplied by 8 and execution continues at the corresponding address, which means there are additional vectors at 0x2c, 0x34and 0x3cin the VT102 ROMs. These are the "half interrupts" because their interrupt numbers are halfway between the integer interrupts.

The VT102 wires the 5.5 interrupt to serial line transmit ready, the 6.5 interrupt to receive ready, the 7.5 interrupt to vertical retrace, and the TRAP interrupt to something else.

The 8080 interrupt handling is represented by the $RSTvariable, which simply contains the interrupt number - for instance, if a vertical retrace happens, a 4is ORed into it.

The 8085 is a bit more complicated, because it has a separate mask for it's "half interrupts" ( $INTMASK), but otherwise also uses a simple bit mask for pending interrupts ( $INTPEND).

The VT102 ROMs seem to be a bit buggy, though - they don't handle all interrupt combinations properly, which is why the emulator only ever invokes interrupts 1, 2 or 4 and the half interrupts, not any combinations.

Apart form this difference, the CPUs are virtually identical (some 8080 support chips are built-in), so they share all the other registers:

# 8080/8085 registersmy ($A, $B, $C, $D, $E, $H, $L); # 8 bit general purposemy ($PC, $SP, $IFF); # program counter, stack pointer, interrupt flagmy ($FA, $FZ, $FS, $FP, $FC); # condition codes (psw)

Although the register names sound more like Z80 registers, they really work like the 8086 registers. If you don't know what I mean with this, you can safely ignore this sentence :)

The $Fx-variables are the condition codes/processor flags. $FA(auxiliary) and $FP(parity) are mostly unimplemented, as they are not used by the VT100.

Nothing needs initialisation, as a $PCof 0is fine and the other registers are initialised by the firmware.

The Opcode Table

With this, we come to the opcode table. Most instructions affect the condition codes in the same way, which is why a convenience function called sf("set flags") is provided:

sub sf { # set flags, full version (ZSC - AP not implemented) $FS = $_[0] & 0x080; $FZ = !($_[0] & 0x0ff); $FC = $_[0] & 0x100; $_[0] &= 0xff;}

For the many instructions which cannot overflow, a special version of sfis provided, sf8:

sub sf8 { # set flags, for 8-bit results (ZSC - AP not implemented) $FS = $_[0] & 0x080; $FZ = !($_[0] & 0x0ff); $FC = 0;}

And lastly, some instructions do not affect the carry flag:

sub sf_nc { # set flags, except carry $FS = $_[0] & 0x080; $FZ = ($_[0] & 0x0ff) == 0; $_[0] &= 0xff;}

The emulator provides a "scratch register", and then initialises the opcode table with something that dies:

my $x; # dummy scratchpad for opcodes# opcode tablemy @op = map { sprintf "status(); die 'unknown op %02x'", $_ } 0x00 .. 0xff;

It is not instantly clear from the code, but the opcode table is simply an array that maps each 8-bit opcode to a string. This string is mostly perl code, but can contain some macros, and is used by the JIT compiler to compile basic blocks.

Some helper arrays that contain expressions for the addressing modes and condition code tests (jumps) are provided as well:

# r/m encodingmy @reg = qw($B $C $D $E $H $L $M[$H*256+$L] $A);# cc encoding. die == unimplemented $FP paritymy @cc = ('!$FZ', '$FZ', '!$FC', '$FC', 'die;', 'die;', '!$FS', '$FS');

With these helper definitions, we can define the opcodes, starting with the most important of all:

$op[0x00] = ''; # nop

I will only show representative examples of some opcode classes - the full opcode table is in the source, of course.

Some opcodes can be put into broad classes, such as all the movinstructions:

# mov r,r / r,M / M,rfor my $s (0..7) { for my $d (0..7) { $op[0x40 + $d * 8 + $s] = "$reg[$d] = $reg[$s]"; # mov }}

Some opcodes can be generated by using the @regand @cctables:

# mvi r / M$op[0x06 + $_ * 8] = "$reg[$_] = IMM8" for 0..7;$op[0x04 + $_ * 8] = "sf_nc ++$reg[$_]" for 0..7; # inr$op[0x05 + $_ * 8] = "sf_nc --$reg[$_]" for 0..7; # dcr$op[0x80 + $_] = 'sf $A += + ' . $reg[$_] for 0..7; # add$op[0xb0 + $_] = 'sf8 $A |= ' . $reg[$_] for 0..7; # ora$op[0xc2 + $_ * 8] = 'BRA IMM16 if ' . $cc[$_] for 0..7; # jcc

The words IMM8, IMM16and BRAare macrosthat are replaced by the immediate 8 or 16 bit constant following the opcode, or implement a branch.

Many opcodes have some manual encoding. Here are some examples:

$op[0xd3] = 'OUT'; # out$op[0xdb] = 'IN'; # in$op[0xf3] = '$IFF = 0'; # di$op[0xfb] = '$IFF = 1'; # ei$op[0xc5] = 'PUSH $B; PUSH $C';$op[0xc1] = '($C, $B) = (POP, POP)'; # pop$op[0xf5] = 'PUSH $A; PUSH +($FS && 0x80) | ($FZ && 0x40) | ($FA && 0x10) | ($FP && 0x04) | ($FC && 0x01)'; # psw$op[0xc6] = 'sf $A += IMM8'; # adi$op[0xeb] = '($D, $E, $H, $L) = ($H, $L, $D, $E)'; # xchg$op[0x2f] = '$A ^= 0xff'; # cma$op[0xc3] = 'JMP IMM16'; # jmp$op[0xc7 + $_ * 8] = "JMP $_ * 8" for 0..7; # rst

Here you can see some more macros - OUTand INare for peripheral in/out, the primary way to access the hardware chips. PUSHand POPpush and pop an 8 bit quantity on the stack, and JMPis a direct 16 bit jump.

The rest of the instructions are hardly more complex, with two exceptions, my personal enemy, the DADinstruction which caused me three days of debugging because it is the only 16 bit instruction that sets flags, and I overlooked that tiny detail, causing almostno problems except some subtle keyboard problems. Which made me trace through and analyze the keyboard decoding function, which is a miracle of code and data compression.

The other exception is DAA, used by decimal arithmetics. This instruction is used only in one place, namely to display some numbers in the Set-Up screens:

# yeah, the fucking setup screen actually uses daa...$op[0x27] = 'my ($h, $l);($h, $l) = ($A >> 4, $A & 15);if ($l > 9 || $FA) {sf $A += 6;($h, $l) = ($A >> 4, $A & 15);}if ($h > 9 || $FC) {$h += 6;$A = ($h * 16 + $l) & 0xff;}'; # daa, almost certainly borked, also, acarry not set by sf

I hope it is clear why I wished that I did not have to implement DAA.

Interestingly enough, I had a similar case when writing an AEG-80/20 emulator (an extremely obscure german computer used in nuclear power plants and similar places) - the AEG 80/20 CPU has bit-field insert/extract instructions, and I was too lazy to actually code them. Instead, I patched out the single use of such an instruction in the whole operating system by a shorter, faster, but equivalent sequence that didn't need it.

Anyways, the other opcodes are pretty straightforward. The only remaining function of possible interest here is the statusfunction, which really is useful for debugging only, but shows how the registers are seen logically:

# print cpu status, for debuggingsub status {my $PC = shift || $PC;printf "%04x/%04x A=%02x BC=%02x:%02x DE=%02x:%02x HL=%02x:%02x ZSCAP=%s: %02x %s/n",$PC, $SP,$A, $B, $C, $D, $E, $H, $L,($FZ ? "1" : "0"). ($FS ? "1" : "0"). ($FC ? "1" : "0"). ($FA ? "1" : "0"). ($FP ? "1" : "0"),$M[$PC], $op[$M[$PC]];}

And with this I conclude this part of the series. The next part will show how this opcode table is used in the actual CPU emulator, which is a single loop that compiles and executes basic blocks, handles interrupts and form time to time updates the hardware state.