For reasons that involve a university student union and a computer club with not enough student members (if you're studying at KTH, please consider joining Stacken) I found myself needing to display a QR code on a VT220 terminal that has a beautiful amber phosphor. This turned out to be harder than I expected.
I carried the heavy thing out of the club's server room and another member hooked it up to his laptop using a combination of DB-25 and DE-9 plugs with RJ-45 connectors on top, a null modem that's no doubt older than me, and an RS-232 USB interface that he just happened to have in his backpack.
After configuring the terminal a little bit (and getting lucky that he had muscle memory for opening the menu after having forgotten the button) we got to see it print some characters, and then everyone discussed the idea of writing demos for it to use on the next member recruitment day.
For a lot of the demo ideas we came up with block characters would come in handy, which we all thought would obviously exist on a terminal like this.
For those unaware and unwilling to click the wikipedia link above, block characters are characters that are comprised of various filled in blocks, which are useful for creating crude graphics. If you have block characters where each block covers one fourth of the character, you've effectively doubled your graphics resolution compared to exclusively using completely filled in characters.
So let's see what characters we've got to work with. Writing a quick program that sends all possible character codes (exclude 0x00 ~ 0x20 and 0x7F ~ 0xA0 or else there will be trouble) plus a little bit of nice formatting shows us that we have ASCII and some latin extensions.
That's nice, but not all that useful for drawing a QR code. Well, let's try anyway, and let # be our "on" pixel.
No, that doesn't look like it'll scan, for several reasons. For one, this aspect ratio is too wrong. Except for with a proprietary extension that nothing supports, QR codes have to be roughly square, and this is not square. I mean, scanners can cope with trapezoids, but probably not straight rectangles like this.
Luckily this is easy to fix. Sending the terminal the escape code ESC#6 (where "ESC" is the ASCII character 0x1b) will turn on double-width mode for the active line (it does not continue on the next line), which, as the name implies, doubles the width of the characters.
Like this.
If you want to turn it off again for some reason, there ESC#5. (Fun fact: this works on XTerm if you turn on TrueType fonts!)
Since double-width mode doesn't carry over after newlines, we have to send the escape code after every line break. Doing so gets us this:
We're getting closer, but I still don't think any QR code scanner will take this. Our "pixels" are too empty. We need filled in blocks. From reading the manual, we can see that the VT220 has more characters than just ASCII and latin extensions. Enabling it is a little trickier than just sending an escape code. We have to send two.
The VT220 has four things (I honestly can't be bothered learning what they are) called G0, G1, G2 and G3, and you can assign different character sets to them. We'll let G0 be ASCII since that's always nice to have around. We can use the escape code ESC)0 to give G1 the "special graphics" set. Then we need to tell the terminal that we want G1 to be printable, and we do that by sending the escape code ESC~. It makes the characters set assigned to G1 display itself when printing characters with code 0xA1 or higher.
The special graphics include a little bit of regular ASCII, some miscellaneous characters that might be useful to someone, as well as some line drawing characters. These may be useful for drawing terminal user interfaces, but they're not all that useful for demos.
Critically missing are the block characters that we all assumed would exist. Luckily, we can fix that, using the last character set, the dynamically redefinable one.
First, we should set it. We can once again pick a new character set for G1, this time the redefinable one, using ESC)@. Now if we print our character sets we see a lot of mirrored question marks. This indicates undefined characters, so let's define one of them. First, we'll need to design it.
Characters are sent to the terminal in sixel format1. What that means in this case is that we define six pixels using six bits, from the graphic's top to bottom, least significant bit to most significant bit. Then, we take this number and add it to the ASCII code for ?, and then the character with the resulting character code will be our six bit tall column. Let's make a column with this pattern:
█
█
█
█
█
█
We count and insert the bits, top to bottom, least significant to most significant, and get the binary number 010001 (17 decimal, 0x11 hexadecimal). The ASCII code for ? is 63 decimal (0x3F hexadecimal), and the sum of these numbers is 80 decimal (0x50 hexadecimal). Looking at an ASCII table (or more realistically, typing it a REPL of choice) we see that that's P, so that will be the character representing this column.
The problem now though is that on the VT220, characters are 10 pixels tall. The solution to this is that we first send the pixels for the top 6 rows, then we put a /, and then we send the pixels for the bottom 4 rows. Those too go from top to bottom, least significant to most significant, although the two most significant bits are ignored, which is quite convenient when making these character graphics by hand.
A serifed letter S might then be written like this: M^ZpbeN?/NEKGLNF?. In an image viewer, that graphic looks like this:
In our case though, we're just going to make one character and have all of its pixels filled in. Being a little lazy about it, we can make it ~~~~~~~~/~~~~~~~~.
Now we need to actually send this, and the command to do this is ESC P1;n;1{@, where n is the character number you want to start on (starts at 1, not 0). In the first printable half, character 1 is 0x21, and in the second half it's 0xA1. Next we have to start sending characters. We can send multiple characters in one go, separating them by semicolons. When we're done sending characters, we send ESC \ to terminate. So the full command we're sending is ESC P1;1;1{@~~~~~~~~/~~~~~~~~ ESC \.
Success! Now, let's try it out with our QR code, replacing the # with this character.
That looks nice and sharp, but doesn't scan. QR codes have a lot of error correction built in, but the top row can't just be missing like that.
It turns out the smallest QR code of the URL I needed was 25x25 pixels, and our terminal is of course the industry standard 80 columns by 24 rows. A 132x24 mode exists, but obviously doesn't help us with the problem that our QR code is too tall for our screen (plus it would ruin the aspect ratio again).
I could use a link shortener, but I feel as if it would look really unprofessional for a club associated with a student union to use a non-university domain when recruiting new member. But the real reason is because it's a really boring solution. Block characters would be a great solution, and now that we know how to create custom characters, we can make our own!
It would be useful if we had a structure to the order we place the block characters in. It would be especially nice to be able to simply specify which blocks we would like to turn on in a character. We can do this by having 16 block characters and ordering them in such a way that each bit in the offset from the starting character represents a block. The way I arbitrarily decided to designate the bits is this: top left, top right, bottom left, bottom right, from the least significant bit to the most significant. So for example, to print a block with the top left and bottom right pixels on, we would print the character 0xA1 + 0b1001 = 0xAA.
I'll skip over the character drawing part because it's horribly boring, so here are the characters in order:
That looks... fine...
Now, using a function I am not at all proud of, we can use these characters to draw the QR code at half size. To summarize what it does, it goes through two lines and two columns at a time, and then combines those four points into one character to output.
This finally works!2 Admittedly it does look a little weird if you look closely, there's some bits that stick out in weird ways, but that's fine, the phones we've tested with had no problem scanning it, at least not directly off the screen. Scanning it off photos doesn't seem to work very well, but that's okay, I don't need that to work.
Okay, no, I'm not actually happy with this. Looking closely at the block characters, the right side of them looks significantly wider than the left side. It looks awful. Absolutely horrible. I can't show this to people, I would die of embarrassment.3 I can't stand it.
The reason why this happens is because the terminal does something quite strange. In memory, characters are no larger than 8x10, but when drawing the characters, the drawing circuitry wants 10 dots per line. For the vast majority of characters it's okay to just draw two blank dots, since we want a blank space after text characters anyway. For the line drawing characters however, this is not okay. The solution? The drawing circuitry simply stretches the last column for two extra dots!
This works great for the one use case it was intended for, and horribly messes up almost everything fun we could do with custom graphics. I wanted to have a little Mario non-copyright infringing human running across the screen using custom characters to implement virtual sprites, but that's just not happening since we can't touch the eighth column of a character without it expanding. I wanted to display a nice hand drawn logo that I paid three dollars for, but it required quite a bit of manipulation to get it to fit, and it's just not as nice anymore. In general, just a huge disappointment.
But for the block characters there's an easy fix! We just need to make the left blocks use five pixels and the right blocks use three. The last column will get duplicated twice, ending up with five in total. And this works!
Tada!
If you want to use these block characters on your VT220, simply ESC P 1;1;1{@????????/????????;^^^^^???/????????;?????^^^/????????;^^^^^^^^/????????;_____???/~~~~~???;~~~~~???/~~~~~???;_____^^^/~~~~~???;~~~~~^^^/~~~~~???;?????___/?????~~~;^^^^^___/?????~~~;?????~~~/?????~~~;^^^^^~~~/?????~~~;________/~~~~~~~~;~~~~~___/~~~~~~~~;_____~~~/~~~~~~~~;~~~~~~~~/~~~~~~~~ ESC \
In conclusion, sometimes you think you have cool ideas, and then reality kills them. Like how the terminal ostensibly supports 19200 bits per second but can't actually consume its input buffer fast enough to utilize it properly, and Linux won't handle the flow control! But at least I got my QR code!
EDIT: Anti-shoutout to Discord for destroying my image links. Thanks guys.
-
Almost, but not quite. The format for specifying individual pixels is the same as with sixel graphics, but only that is. There's no extra "commands", you're just specifying pixels.
-
Well, this one doesn't, but that's because I've randomized some of the lines to avoid broadcasting the exact URL I used.
-
That's a lie, I did show this to people and I'm still alive.