THE RANT / THE SCHPLOG
Schmorp's POD Blog a.k.a. THE RANT
a.k.a. the blog that cannot decide on a name

This document was first published 2015-11-17 02:13:33, and last modified 2015-11-17 02:13:33.

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:

VT100

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.

VT101

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.

VT102

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.

VT103

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).

VT105

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.

VT125

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

VT131

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

VT132

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

VT180

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 vt102 emulator 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 firmware
2000-2bff 3KB of RAM (firmware + screen RAM)
2c00-2fff 1KB extra screen RAM (AVO)
3000-3fff 2KB (4 bit per address) of character attribute RAM
4000-7fff unused
8000-9fff 8KB (4x2) optional expansion ROM on the AVO
a000-bfff 8KB (1x8) optional expansion ROM on the AVO
c000-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 contents
if ($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 support

my $RST     = 0; # pending interrupts (external interrupt logic)
my $INTMASK = 7; # 8085 half interrupt mask
my $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 RST instructions 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:  di
        lxi     sp,X204e
        jmp     X003b

X0008:  call    X00fd
        ei
        ret

X0010:  call    X03cc
        ei
        ret

X0018:  call    X03cc
        call    X00fd
        ei
        ret

X0020:  call    X04cf
        ret

X0028:  call    X04cf
        ret

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

X0008 is interrupt #1 (keyboard input), which handles the keyboard, enables interrupts and returns. Similarly, X0010 handles the serial line, enables interrupts, and returns.

X0018 is 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.5 and 7.5 interrupt 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, 0x34 and 0x3c in 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 $RST variable, which simply contains the interrupt number - for instance, if a vertical retrace happens, a 4 is 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 from this difference, the CPUs are virtually identical (some 8080 support chips are built-in), so they share all the other registers:

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

Although the register names sound more like Z80 registers (the Z80 is basically an 8080 clone), 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 $PC of 0 is 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 sf is 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 table
my @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 encoding
my @reg = qw($B $C $D $E $H $L $M[$H*256+$L] $A);

# cc encoding. die == unimplemented $FP parity
my @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 mov instructions:

# mov r,r / r,M / M,r
for 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 @reg and @cc tables:

# 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, IMM16 and BRA are macros that 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 - OUT and IN are for peripheral in/out, the primary way to access the hardware chips. PUSH and POP push and pop an 8 bit quantity on the stack, and JMP is a direct 16 bit jump.

The rest of the instructions are hardly more complex, with two exceptions, my personal enemy, the DAD instruction 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 almost no 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 status function, which really is useful for debugging only, but shows how the registers are seen logically:

# print cpu status, for debugging
sub 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.