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

This document was published 2015-12-16 19:27:09, and since then has not been materially modified.

Emulating VT102 Hardware in Perl - Part 5: Video Games and Conclusion

The fifth part in the VT102 emulator series will deal with the VT100/102-specific hardware, mostly the video processor, the NVRAM and the keyboard and serial I/O.

The Video Processor

Unlike the "video processor" in many contemporary machines (the Atari 2600 comes to mind), the video processor in the VT100 actually deserves it's name: It is capable of interpreting "display lists", which are relatively primitive programs that nevertheless allow a great deal of complexity to be implemented: The screen buffer is not actually a rectangular storage area, but consists of scanline commands which need not be in order of display. If you know Copper lists from the Amiga you have the right idea - the video processor executes a list of commands.

Every vertical retrace, the video processor starts reading the screen RAM at offset zero, which is really the address 0x2000. It expects to find character data here, that is, 7 bit codes that indicate single glyphs, and a single attribute bit. The actual glyphs are stored in the character ROM, but the encoding is a superset of ASCII.

The single attribute bit can be configured to achieve various effects. When the AVO is installed, there are an additional 4 attribute bits per character in the extra AVO RAM, and the 8th bit in the screen buffer will be configured to be "reverse video".

The characters will be displayed horizontally and can have various attributes and special shapes (condensed, stretched, double width, double height, underline and so on).

When the video processor hits a 0x7f byte (or 0xff, not used in the VT100), the character data for this row ends, and two control bytes follow that form a 16 bit word.

The three high bits in this word indicate whether this line is part of the scrolling region (for split screen super duper smooth scrolling) and whether the line consists of normal, condensed (132 chars/line), double width or double height characters. The low 13 bits form the address of the next row (starting at 0x2000).

Typical Screen Buffer memory layout.

In the actual VT100, the first 6 lines or so are empty, that is, they start with 0x7f, and are used for the vertical retrace period. For 50Hz PAL mode, this is 5 rows, for 60Hz NTSC mode it's 2. The last of these then skips over the housekeeping memory and CPU stack of the VT100 firmware, so the first visible line usually starts at address 0x22c0. Since every line has a pointer to the line following it (they form a singly linked list), it is possible to reorder lines (for example, for scrolling) without actually copying the character data for the line, but simply by reordering the list members, and indeed, the VT100 uses this to speed up scrolling.

Since there is no stop code, the question might arise on how the video processor knows how to stop. Well, it doesn't, really - the VT100 firmware generates an empty scanline after the last row of screen data which points to itself, forming an endless loop. Since the video processor is reset at every vertical retrace and starts again at 0x2000, this is enough to make the whole thing work.

The firmware also uses another "trick" ability to generate full-screen test patterns, by having a single scan line that is "longer than allowed":

 0 │**************************************************************************...
video overflow

The vt102 display function stops with an error after 140 characters. The real hardware would simply display them, and since since it never gets a command to jump to another scanline, would draw this row again and again. You can fill the RAM with any pattern that doesn't contain 0x7f, and the first 80 of the characters will be repeated on the screen.

The technical manual linked to in part 1 goes into a lot more detail regarding the video hardware (chapter 4.6) and logical screen operations (chapter 4.7), so if any details elude you, go there to find an answer.

Control Registers

The video processor has a bunch of control registers to control various aspects of the display, such as bit stretching, condensed character display, how attribute bits map to actual attributes, smooth scrolling and so on. Most of these are not needed to understand how the VT100 reacts to various command sequences, and are not implemented (or implementable), so will be ignored.

Let's start with the DC11 (device control 11) register:

sub out_a2 { # device control 011
   my $dc11 = 0x0f & shift;

   $DC11_REVERSE = 1 if $dc11 == 0b1010;
   $DC11_REVERSE = 0 if $dc11 == 0b1011;

Of the four bits in DC11, only the full screen reverse bit is stored for later use.

sub out_d2 { } # device control 012, 0..3 == 80c/132c/60hz/50hz
sub out_42 { } # brightness

Device control 12 is completely ignored by vt102. As is the screen brightness setting.

Screen Display

Since most of the hardware settings are ignored, the only significant amount of work is done when displaying the screen, i.e., when the mainloop calls the display function.

The display function is pretty self-contained, and only needs a few helper tables. First, to map characters to UTF-8 sequences for screen output:

# video emulation

binmode STDOUT;

my @CHARMAP = ( # acschars / chars 0..31
   " "       , "\x{29eb}", "\x{2592}", "\x{2409}",
   "\x{240c}", "\x{240d}", "\x{240a}", "\x{00b0}",
   "\x{00b1}", "\x{2424}", "\x{240b}", "\x{2518}",
   "\x{2510}", "\x{250c}", "\x{2514}", "\x{253c}",
   "\x{23ba}", "\x{23bb}", "\x{2500}", "\x{23bc}",
   "\x{23bd}", "\x{251c}", "\x{2524}", "\x{2534}",
   "\x{252c}", "\x{2502}", "\x{2264}", "\x{2265}",
   "\x{03c0}", "\x{2260}", "\x{00a3}", "\x{00b7}",
   (map chr, 0x020 .. 0x7e),

utf8::encode $_ for @CHARMAP;

(Spot the snowman!)

The @SGR array is a cache that maps attribute bits to SGR (select graphic rendition) sequences used to tell the (outside VT100 compatible) terminal emulator this (vt102)terminal emulator runs in to switch character rendition:

my @SGR; # sgr sequences for attributes

for (0x00 .. 0xff) {
   my $sgr = "";

   # ~1 sgr 5 blink
   # ~2 sgr 4 underline
   # ~4 sgr 1 bold
   # 0x80 in attr, sgr 7, reversed

   $sgr .= ";5" unless $_ & 0x01;
   $sgr .= ";4" unless $_ & 0x02;
   $sgr .= ";1" unless $_ & 0x04;
   $sgr .= ";7" if     $_ & 0x80;

   $SGR[$_] = "\e[${sgr}m";

And finally a table that maps LED bits to LED labels:

my @LED = $VT102
   : qw(L4 L3     L2  L1  LOCKED LOCAL SCAN BEEP);

With this, we can have a look at display:


# display screen
sub display {
   # this is for the powersave mode - check whether the cursor is on here,
   # and only allow powersave later when it was on the last display time
   $CURSOR_IS_ON = $M[$VT102 ? 0x207b : 0x21ba];

   my $leds =
      join " ",
         map $KSTATUS & 2**$_ ? "\e[7m$LED[$_]\e[m" : "$LED[$_]",
            reverse 0 .. $#LED;

   my $scr = sprintf "\e[H--- LED [ %s ] CLK %d\e[K\n", $leds, $CLK;

   $scr .= "\e[?5" . ($DC11_REVERSE ? "h" : "l");

   my $i = 0x2000;

   for my $y (0 .. 25) { # ntsc, two vblank delay lines, up to 24 text lines

First it sets $CURSOR_IS_ON by reading some magic address in the firmware housekeeping memory. This is a hint to the powersave functionality in the mainloop - if you use custom firmware and this hint fails, the worst outcome is that the emulator goes into powersave when the cursor is invisible, which isn't a deal breaker.

Next it creates and prints a status line - this involves moving the cursor to the top left corner as well and potentially setting reverse video for the whole screen. The screen is not cleared to avoid flickering: instead every line ends with a clear-to-end-of-line command, and the full output ends with a clear-to-end-of-screen command. All of this is not being written to STDOUT immediately. The function collects all output in $scr first and writes this in one go later.

Then the emulator initialises the current "video program offset" to 0x2000 and then iterates over up to 26 lines, which is enough for NTSC.

For each line, it appends a row count and the beginning | delimiter, and then iterates over up to 140 characters or codes. Usually this is more than there are characters in a row, so this loop will almost never iterate the full 140 times:

      my $prev_attr;
      my ($c, $attr); # declare here for speedup

      $scr .= sprintf "%2d \xe2\x94\x82", $y;

      for (0..139) {

The first thing the character/column loop does is fetch the next code, and check whether it is the magic 0x7f end marker:

         $c = $M[$i];

         if ($c == 0x7f) { # also 0xff, but the firmware avoids that
            $scr .= "\e[m\xe2\x94\x82\e[K\n";

            my $a1 = $M[$i + 1];
            my $a0 = $M[$i + 2];

            $i = 0x2000 + (($a1 * 256 + $a0) & 0xfff);

            next line;

If it is, it outputs the end | delimiter, resets the rendition, clears the remaining space on the line and goes to the next line. It also fetches the next row address, dutifully ignores any of the special flags and skips to the next row.

Otherwise $c contains a 7 bit character index and a single bit of attributes. This bit is combined with the 4 extra bits from the AVO attribute RAM, and if the rendition is different than the current one ($prev_attr), outputs the new rendition:

         $scr .= $SGR[$prev_attr = $attr]
            if $prev_attr != ($attr = ($M[$i++ + 0x1000] & 15) | ($c & 0x80));

         $scr .= $CHARMAP[$c & 0x7f];

The code is somewhat tricky/dirty, but it gets the job done reasonably fast, which was the goal.

If the loop ever ends normally, we have a video overflow:


      $scr .= "\e[K\nvideo overflow\e[K\n";

And after the row loop, a full screen has been displayed, so output a clear-to-end-of-screen command and actually write it to STDOUT:

   $scr .= "\e[m\e[J";

   syswrite STDOUT, $scr;

And that is all I have to say about the video output. Again, full details can be found in the technical manual by DEC.

Mopping Up

The video processor was the last interesting (to me) piece of hardware, and in fact, I think I have described all parts of the hardware that the emulator actually implements. The only things left in the script, which, after all, is less than a thousand lines long, are some additional status bits, ignoring a whole bunch of in/out instructions dealing with hardware that is not installed, and minor hacks, such as initialising the tty and pty:

if ($KBD) {
   system "stty -icanon -icrnl -inlcr -echo min 1 time 0"; # -isig
   eval q{ sub END { system "stty sane" } };
   $SIG{INT} = $SIG{TERM} = $SIG{QUIT} = sub { exit 1 };

# process/pty management

if (1) {
   require IO::Pty;
   $PTY = IO::Pty->new;

   my $slave = $PTY->slave;

   $PTY->set_winsize (24, 80);

   unless (fork) {
      $ENV{LC_ALL} = "C";
      $ENV{TERM} = $VT102 ? "vt102" : "vt100";

      close $PTY;

      open STDIN , "<&", $slave;
      open STDOUT, ">&", $slave;
      open STDERR, ">&", $slave;

      system "stty ixoff erase ^H";


      @ARGV = "sh" unless @ARGV;
      exec @ARGV;

} else {
   open $PTY, "+</dev/null"
      or die "/dev/null: $!";

What's left for you is to try it out and possibly look at the script in context. If anything significant is missing or unclear, or you wish I had talked about other things, drop me a note!