Virtual DEC VT240 Terminal
Date: 2024-05-07, Author: unknown
Many decades ago, DEC developed the VT series terminals. One of the most widely known terminals is probably the VT102, a simple 7bit ASCII text terminal with basic screen control features. Later variants of the VT100 added graphics support, but it still remained a basic 7bit terminal.
At the end of 1983, DEC introduced the VT200 series of terminals with significantly upgraded capabilities compared to the VT100 series: the VT220 as a pure text terminal with 8bit ASCII and the VT240 with multiple different graphics modes, even with color! Even today, only very few terminal emulators support all features of the VT240 with reasonable accuracy.
Emulating the VT240
Emulating the VT240 is straight forward: the Reference Manual contains most of the relevant information about how to parse and interpret the data stream, while the Owner’s Manual describes setup and configuration in detail, including the setup screens and all available keyboard layouts.
The technology of choice to implement the virtual VT240 is of course the C programming language with modern OpenGL for visuals and GLFW for OpenGL context initialization. Although the real VT240 seems to use a single graphics framebuffer, it is in fact easier to implement a separate text buffer and graphics framebuffer and combine everything in a shader.
The VT240 emulator is roughly structured in four major components: subroutines which implement fundamental VT240 behavior like screen control commands, the data stream parser, the rendering logic, and keyboard handling.
Parsing the VT240 data stream is the first step: a big
finite state machine decodes the data stream character
by character. Although this might not be the most
elegant implementation, it is easy to understand and
maintain. In addition to the state
which
selects the state within the FSM, parameters have to be
remembered when parsing certain VT240 command sequences
like the SGR sequence.
The parser dispatches commands to the corresponding subroutines which handle the relevant commands, or in case of trivial commands like setting some flags, it directly interprets them. The result is a big text buffer with character cells containing the glyphs and attributes, as well as a framebuffer with graphics.
Obtaining the VT240 Font
Before we can render the VT240 screen, we need the correct font. Clearly, the VT240 has a built-in font, and firmware ROM dumps are available, so we should be able to get the font. Since a character cell in the VT240 is 10x10 pixels and according to the manual characters only use 7x10 pixels, one can reasonably assume that the pixels of a line are packed into a single byte. Now we can look at the ROM dumps and see if we can find them.
Unfortunately, there is no tool available which can
directly help us with searching the font in the ROM
files, so we have to write our own. The idea is to plot
the bits of every byte as lines of pixels. The core of
the tool is the rendering function, where
data
is the raw data loaded from the ROM
file:
private static final int SIZE = 5; @Override protected void paintComponent(Graphics g) { int width = getWidth(); int height = getHeight(); int cellsX = width / (9 * SIZE); int cellsY = height / SIZE; int cells = cellsX * cellsY; int max = Math.min(data.length, cells); g.setColor(getBackground()); g.fillRect(0, 0, width, height); // plot data for(int i = 0; i < max; i++) { int col = i / cellsY; int row = i % cellsY; int bits = Byte.toUnsignedInt(data[i]); for(int j = 0; j < 8; j++) { boolean bit = (bits & (1 << (7 - j))) != 0; if(bit) { int x = col * 9 * SIZE + j * SIZE; int y = row * SIZE; g.setColor(Color.GREEN); g.fillRect(x, y, SIZE, SIZE); } } } // draw separators g.setColor(Color.GRAY); for(int i = 0; i < max / cellsY; i++) { int x = i * 9 * SIZE + 8 * SIZE + SIZE / 2; g.drawLine(x, 0, x, height); } }
The complete source code of the analysis tool is available here: BitView.java
After checking all the ROM files, we eventually see
characters in the 23-008e6-00.e100
file:
It is also very obvious that there are actually two different fonts: one for the 80 column mode and a separate one for the 132 column mode. This also explains how 132 column mode actually works: text is simply rendered with a different font into the same hardware 800x240 framebuffer. There is no pixel doubling like in previous VT series terminals including the VT220 involved. This also explains why graphics in the graphics mode looks just fine, without any distortions that would occur as a result of pixel doubling.
If you look at the fonts carefully, you will notice that the pixel doubling that was used in previous VT series terminals is now part of the font data itself and characters in 80 column mode use the full 8x10 pixels available, in contrast to the explanation in the manual. In 132 column mode, characters have a size of 6x10 pixels, which is again one more than what is explained in the manual. But there is something strange: in previous VT series terminals, the last column of pixel data was extended to the full 10 pixel wide cell for line drawing characters. This cannot be done on the VT240, since many text characters use all 8 bits. Instead, it seems like only the dedicated line drawing character range uses extension of the outermost pixels to fill the whole character cell.
Once we figured out the offset of the two fonts in the ROM dump file, we can simply extract them and generate a C file with the pixel data. Now that we have the fonts, we can start implementing the rendering code.
Rendering the VT240 Screen
A VT240 can render 80x24 characters or 132x24 characters into a 800x240 framebuffer. This 800x240 image is then stretched to what would more reasonably be 800x480 on a modern standard computer screen. Every character cell contains a set of attributes like bold and underline as well as the internal character code. In addition to the character cell attributes, every line also has line attributes like double width and double height.
Once all the commands from the VT240 data stream are processed and the text buffer and framebuffer are filled, it is time to render it to the screen. Obviously the simplest approach to rendering is to pass the text buffer with the character cells to a shader as a texture, together with the font and line attributes. The shader can then sample the text and attributes from the text texture, use that information to look up the pixels from the font texture, and eventually render the pixels, all in a single draw call.
Modern graphics cards support integer textures, so we can directly pass an almost arbitrary integer array to the shader and sample it as a “texture” there. This allows us to pass the raw text cells as well as line attributes and font bits to the shader.
According to Microsoft’s Windows Terminal developers it takes a PhD to implement this (or to be fair, something similar at least), but in the basic form of what the VT240 needs, it is straight forward to implement. However, we want to go one step further and essentially do everything related to text rendering in the shader, including SGR (“Select Graphic Rendition”) processing, cursor display, and even font decoding.
In addition to rendering of the text and graphics content, we also mix it with the setup screen if the setup screen is currently active. This means if the setup screen is active, the entire screen content is shifted up by 8 lines and the lowest 8 lines on screen are taken from the separate setup screen buffers. This means activating and deactivating the setup screen requires no changes to the regular terminal data and can therefore be done with no additional overhead. To give you an idea what the setup screen looks like, take a look at the following screenshot:
Preparing the Textures
To pass character cells, line attributes, and fonts to shaders, we have to prepare them carefully. Graphics cards support integer textures, but we have to carefully lay out the data in memory to be able to efficiently access it from the shader.
In particular, we have to remember that graphics cards prefer data to consist of 32bit words. A character cell certainly needs 16bit to store the glyph ID, since we have 288 different glyphs. In addition, every character cell also has attributes which also conveniently fit into 16bit. We can therefore combine the glyph ID and cell attributes into a simple struct:
typedef struct { u16 glyph; u16 attr; } VT240CELL;
This means every character cell is now 32bit large and can therefore easily be transferred to the graphics card and accessed from a shader. Even better, in the shader we can now access both components individually by defining the texture to consist of two 16bit channels:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RG16UI, vt->columns, 80, 0, GL_RG_INTEGER, GL_UNSIGNED_SHORT, (GLvoid*) vt->text);
The GLSL shader code to read a character cell is now rather simple:
uvec2 textcell = texelFetch(text, cellcoord, 0).rg; uint glyph = textcell.r; uint attr = textcell.g;
Preparing the font bits is a bit more complex, since its data is not aligned to 32bit, unfortunately. We have to explicitly change the alignment for this texture upload to avoid unintended padding. After adjusting the alignment, we can directly upload the font bits as they were extracted from the firmware ROM dump:
#define FONT_WIDTH 10 #define FONT_HEIGHT 288 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); /* set alignment to 1 */ glTexImage2D(GL_TEXTURE_2D, 0, GL_R8UI, FONT_WIDTH, FONT_HEIGHT, 0, GL_RED_INTEGER, GL_UNSIGNED_BYTE, vt240_80colfont);
Of course, we must not forget to restore the alignment
to 4
after all the font textures are
uploaded. By defining the font texture this way, we can
then retrieve the glyph bits by looking up the texel at
texture coordinates
texcoord = ivec2(pos.y, glyph)
where
pos.y
is the line within the glyph. The 8
columns of the glyph are encoded in the 8 bits of the
texel.
Font Decoding via Shader
Since we have our fonts in textures now, we can attempt to decode font glyphs within a shader. Let us first look at the complete decoding routine in GLSL and then figure out how it works in detail, because this is an approach you might not have seen so far:
bool get_font_pixel(uint glyph, uvec2 pos, usampler2D font) { ivec2 fontpos = ivec2(int(pos.y), int(glyph)); if(pos.y >= 10 || glyph >= 288) { return false; } uint bits = texelFetch(font, fontpos, 0).r; bool is_fullcell = glyph >= 0x10Bu && glyph <= 0x119u; bool bit; if(pos.x > 0u && pos.x < 9u) { bit = ((bits << (pos.x - 1u)) & 0x80u) != 0u; } else if(pos.x == 9u && is_fullcell) { bit = (bits & 1u) != 0u; } else if(is_fullcell) { bit = (bits & 0x80u) != 0u; } else { bit = false; } return bit; }
This routine takes the glyph ID as glyph
argument as well as the coordinates within the glyph
(pos
) and a texture sampler for the font
(font
). First we compute the texture
coordinates within the font texture as described
earlier. We should also clamp the coordinates just to
be sure, because texelFetch
with an
out-of-bounds read would result in undefined behavior.
The texelFetch
operation allows us to
directly read texels from the texture sampler, which
means font bits from the font texture, without going
through any interpolation steps. This even bypasses
interpolation steps if they were explicitly configured
on the font texture for some reason. It directly returns
the raw unsigned bytes of the font bits so we can then
decode them to obtain the individual pixels.
Remember how line drawing characters have to be
stretched to the full character cell? We decide this (at
least for now) by checking the glyph ID for the range
0x10B-0x119. When stretching a glyph to the full
character cell, we simply extend the first and last
pixel such that the glyph is centered in case of a line
drawing character. If the glyph is not a line drawing
character, we also center it, but set the two border
pixels to 0
instead.
Now you might ask yourself: in 80 column mode a glyph is
10x10 pixels large but only 6x10 pixels in 132 column
mode, doesn’t this mean centering the glyph is a
problem? As it turns out, it is perfectly OK to only
look at the first 7 pixels in this case. The first pixel
at column 0
has to be ignored in 132 column
mode, the next 6 pixels define the character. The
remaining pixels repeat pixels and have to be ignored
too.
Decoding of the font bits is simply a bit mask operation to determine if the specific bit in the font byte is set, while at the same time applying the centering and optional border extension operation.
Rendering the VT240 Screen via Shaders
We will render the screen in multiple passes: first we render the raw VT240 screen, then we will apply various graphics effects to make the result more appealing in later rendering passes. We start with the first pass, which renders the entire VT240 screen to a render target using a single draw call.
The pixel shader is as simple as it can be, we simply
pass everything to the fragment shader and normalize the
coordinates to the range 0
to
1
, which are then passed on to the fragment
shader.
The magic happens in the fragment shader. We will skip over the low level details here, but we will still take a close look at the underlying ideas and algorithms. After all, we are handling the entire text rendering within a shader, with no CPU involvement at all.
The most important concept to understand when rendering the text buffer within a pixel shader is that the process is precisely the inverse of how it would work when rendering the screen on the CPU: instead of drawing glyphs or pixels onto the screen, we look at a specific pixel on the screen and have to figure out its color by figuring out which character cell it is from, and which font bit it holds. This means for every pixel on the screen, we have to figure out the character cell coordinates this pixel belongs to, read the glyph ID and attributes from the character cell as well as the relevant line attributes, process the attributes to figure out the colors, figure out the coordinates within the glyph to retrieve the relevant bit from the font, and then apply the correct color according to the attributes and font bit.
In 132 column mode, not all pixels on a line are used. There are 8 unused pixels, and we just center the text with a border of 4 pixels on both sides. We cannot just set this border to black though, because the user might configure the reverse video option (“Dark Text, Light Screen”), in which case we would get a black border. Instead, we will just use the background color of the adjacent character cell to set the border pixels.
A VT240 uses a 2bit framebuffer and can distinguish between 4 different colors: on a monochrome version of the VT240, this results in white, 2 shades of gray, and black. On a color version of the VT240, this results in 3 different colors (red, green, blue) as well as black. We will describe colors with a color ID. The color ID is an index into a 2bit palette, which is passed from the CPU side to the graphics card. It is therefore easy to adjust the palette without touching the shader code at all. The monochrome version of the VT240 was available with different CRT screens which had different phosphor colors, including white, amber, and green. These colors can be implemented via the palette by defining the 4 colors for a specific phosphor color.
To obtain the final color, we use the color ID from
VT240GetColor
which describes both the
color of the “foreground” of a pixel and the color of
the “background” of a pixel. This is necessary because
the colors on a VT240 are quite fancy: just because a
pixel is “off” does not mean it is automatically black.
Instead, the character cell attributes influence the
color of both the “foreground” and the
“background”. Even worse, just because the reverse video
mode attribute is set, it does not mean that
the foreground and background colors are swapped. The
VT240GetColor
function therefore has to
return both the color IDs for the foreground and the
background color. We then use the
colorscheme
lookup table to decode the
color ID to the actual color.
Remember how the VT240 can display graphics? We have to combine the graphics framebuffer with the text. To do this, we just sample from the graphics framebuffer texture in the pixel shader, offset it if the setup screen is active, and then add it to the text. One could think about a better way to combine it with the text, but a simple addition seems to be good enough for now.
With this, the rendering pass for the raw VT240 screen is done. The next screenshot shows what the raw VT240 screen looks like, without any post-processing:
Of course, now we have to apply post-processing steps to make the result look fancy.
Scanlines
The most obvious post-processing effect we have to apply is scanlines. The idea is simple: we modulate the intensity of pixels with some function. The natural choice is to use a sine function, since this roughly approximates what an electron ray would look like on a CRT screen. The following screenshot shows what kind of effect we want to build:
However, we also need an extra parameter to control the effect: the focus. With just the sine function, we would get evenly spaced on/off lines. However, the focus parameter allows us to make lines thicker or thinner.
First, we compute φ, the angle of the sine function for
the current line. We have to be careful here to make
sure the sine function starts with 0
at the
top of the screen, otherwise we would get a graphics
glitch there.
const int width = 800; const int height = 240; // the VT220/VT240 has 240 lines float line = pos.y * float(height); float linebase = float(int(line)); float lineoffset = line - linebase; float phi = 2.0 * M_PI * lineoffset;
Now it is time to implement our fancy focus. We simply
use the pow
function where
focus
is the exponent. Of course, we also
have to offset the sine function by 1 and divide by 2 to
convert it from the range of [-1;1] to the range [0;1].
float intensity = pow((sin(phi) + 1.0) / 2.0, focus);
There are two more special cases we have to handle: if
we are at over 75% of the line, we have to sample from
the next line, otherwise we would get a
graphics glitch. This is because our sine function
starts at φ = 0
which means a value of
0.5
. Therefore, we reach 0
at
75% of the line.
We also have to be careful about the last line: we
absolutely do not want to start a new 25% of a line.
Unfortunately, we cannot just shift the whole image down
by 25% of a line, because then the scanline effect would
not work at a resolution of 800x480. The way we chose
our scanline function magically results in every other
line on the screen being 0
at 800x480,
which is exactly what we want.
// jump to next line if we are over 75% of the line int texline = int(linebase); if(lineoffset > 0.75 && (texline + 1) < height) { texline++; } // blank last line / avoid 25% of incorrect color if(lineoffset > 0.75 && (texline + 1) == height) { intensity = 0.0; }
We can now test different values for the
focus
parameter:
The result so far looks quite decent with
focus = 0.75
, but something still looks
clearly wrong if you look carefully:
Phosphor Simulation
With scanlines implemented, we still have to simulate another effect of a CRT: the behavior of phosphor. Just because a pixel is “on” next to another pixel that is “off” does not mean there is an abrupt transition. Instead, this transition is somewhat gradual, due to how analog electronics as well as phosphor in the CRT works. We have to roughly approximate this effect too.
For this effect, we first compute the position of the screen pixel within the VT240 pixel:
float column = pos.x * float(width); float colbase = float(int(column)); float xblend = column - colbase;
Next, we fetch two pixels from the VT240 screen, the current one and the previous one:
// first sample: current pixel ivec2 texcoord0 = ivec2(int(colbase), texline); vec4 fb0 = texelFetch(vt240_screen, texcoord0, 0); // second sample: current - 1 ivec2 texcoord1 = texcoord0 - ivec2(1, 0); bool blank0 = texcoord1.x < 0; if(blank0) { texcoord1.x = 0; } vec4 fb1 = texelFetch(vt240_screen, texcoord1, 0); if(blank0) { // blank it if we are outside of the screen fb1 = vec4(0.0); }
We can now use the xblend
factor to combine
both samples to achieve basic linear interpolation,
which turns out to be totally sufficient for our
rudimentary phosphor simulation:
vec4 result = mix(fb1, fb0, xblend);
In reality, the phosphor function is closer to an exponential function, but for now the linear approximation looks good enough and most people will not notice any difference anyway, since this difference would only really be visible on very large screens.
With the rudimentary phosphor simulation implemented, our virtual VT240 already looks decent:
Finalizing the Rendering Process
The only thing still missing is some glow. We
simply use the raw 800x240 pixel VT240 framebuffer and
blur it with a basic Gaussian blur. We then sample from
it in our post-processing shader and use the
pow
function to adjust the shape, before
combining it with the scanline modulated VT240 screen.
In addition, we add some scanline modulated extra glow
using the pow
function again.
// get glow vec4 glow = pow(texture(blur_texture, pos), vec4(glow_control)); if(!enable_glow) { glow = vec4(0.0); } // combine VT240 screen with scanlines and a bit of glow vec4 vt240 = (result + pow(glow, vec4(2.0)) * 0.2) * vec4(intensity); // combine with glow for the final result color = vec4((vt240 + glow * glow_intensity).rgb, 1.0);
With this, the entire rendering process of the VT240 is done. Just look at it, it looks beautiful!
But just rendering the screen is not sufficient, we also have to somehow interact with our virtual VT240.
Keyboard Handling
Retrieving input from the keyboard is a lot harder than one might expect. Although one would assume that it should be easy to simply retrieve key up/down events, this turns out to be incredibly complicated if these events are supposed to honor the current keyboard layout. What makes it even more complicated is that the VT240 supports various key combinations, like when holding down the CTRL key. The standard keyboard handlers of GLFW only support key up/down without honoring the keyboard layout as well as key characters without providing separate up/down events. It is our task to combine all these events into something we can use.
Funnily enough, this also allows us to implement all the VT240 keyboard layouts in addition to the native layout used by the computer.
The basic idea is to figure out which VT240 key is
pressed during the GLFW key down event, store this key,
and then during autorepeat repeat this previously stored
key, until it is released again. The details are too
complex and boring at the same time to be described
here, but feel free to take a look at the file at
src/keyboard.c
in the git repository to
figure out how this works in detail.
The VT240 also has some keys which are not present on a standard PC keyboard, like a few additional function keys. The F13-F20 are emulated by holding down CTRL or ALT while pressing F3-F10. The only thing that is not implemented at the moment is dead keys and compose key sequences for the various VT240 layouts. Getting this right will require quite a lot of extra work in the future.
WASM Build
Until now, our virtual VT240 is a native GLFW program for Linux. Many people love to run software in their browser, so how hard can it be to port the entire VT240 to the browser? As it turns out, the processing and rendering parts are trivial to port via emscripten. Only some minor modifications to the shader code are necessary to make it compatible with OpenGL ES 3.0, which is what WebGL 2.0 is based on.
Remember how the keyboard handling was painful in the native version of the virtual VT240? Welcome to another whole world of pain that is keyboard handling in the WASM build. Both the GLFW and the GLUT emulation in emscripten is broken in different ways and the only sane way to deal with keyboard input is by writing our own custom keyboard handler which directly uses emscripten built-ins.
Every key in JavaScript has an associated
key code that might be more accurately
described as a key name. This key code is
unique for every key on the keyboard and allows
distinguishing between number keys and keypad keys and
so on. Emscripten allows registering a keyboard handler
which retrieves these key codes and that is exactly what
we use for our virtual VT240 in the browser. If you want
to see the full glory of this insanity, feel free to
take a look at the source file at
web/src/keyboard.c
in the git repository.
Documentation
Every proper program needs proper documentation. On Linux, such documentation is usually provided in the form of man pages. Man pages are written in an ancient markup language, which is as old as UNIX itself: the troff language with the man macro package. But since it is fun to learn ancient technology, it’s a good exercise to write a proper man page for the VT240 terminal emulator program itself, which, as expected, describes its invocation and basic usage.
Of course, just the invocation would be boring. There is the original VT240 documentation from DEC in form of hardcopy manuals, like the Programmer’s Reference Manual. To properly learn the groff language (the current implementation of troff on Linux), which is used for man pages, the source code repository of the VT240 emulator also contains a translation of the hardcopy Programmer’s Reference Manual (excluding the ReGIS and Tektronix 4014 sections as of now) as well as the complete Programmer Pocket Guide.
With this translated version of the original DEC manuals in man page format, you can now read the reference manual and pocket guide in your terminal. You can even properly search in these manuals now, for the first time ever.
Conclusion
As you can see, the implementation of a virtual VT240 is rather straight forward, even when implementing the entire render logic in shaders. Only the keyboard handling is painful, because there is no simple way to obtain key up/down events which honor the computer’s keyboard layout and also work when the CTRL key is pressed.
The complete implementation of this virtual VT240 can be found on Github at https://github.com/unknown-technologies/vt240
Of course, now you might wonder: but what does this all have to do with the topic of this website, which should be about audio hardware? The Emulight project consists of some fancy digital audio modules, some of which can be remote controlled via RS232. A good emulation of a VT240 allows implementing advanced text based user interfaces which can also include graphics if necessary.