Epson HX-20 Video Player
To push the Epson HX-20 to its limits, I have programmed a streaming video player for it. The project consists of two parts; an assembly program that runs on the HX-20 which receives the video data and a Linux client in C that sends it. The video data transfer is done on the "high speed" 38400 baud link on the 5-pin "Serial" port of the HX-20.
The resolution of the HX-20 is just 120x32 monochrome pixels and data must be pushed to the LCD controllers from the CPU. The video data transfer protocol uses 2 bytes to update one pixel anywhere on the screen. With 38400 baud this makes it possible to update 2400 pixels each second, or 100 per frame with 24 FPS. The Linux client sequentially reads simple PBM images.
An existing video file can be converted with ffmpeg and ImageMagick like so:
ffmpeg -i <video-file> out%04d.png for i in *.png; do convert "$i" -resize 120x32\! "${i/.png/.pbm}"; done
To help with development my hex20 emulator was updated to support emulation of the "high speed" serial interface, which is now available in version 0.4 and in the GitHub repository.
Here is the assembly code, which can be assembled with dasm:
processor hd6303 ; Registers: PORT2 equ $03 TRCSR equ $11 RDR equ $12 LCDSEL equ $26 LCDDAT equ $2A GATEB equ $28 org $1000 ; Run from HX-20 RAM. ; Program: start ; Set P22 to redirect SCI serial to external connector: ldaa PORT2 nega anda 0x04 staa PORT2 sei ; ; Read first serial byte, the LCD number (9 to 14) and LCD row data. poll1 tst TRCSR bpl poll1 ldaa RDR beq poll1 ; If byte is 0 then go back, this can be used to sync. tab ; Prepare B register for row data. ; anda #%00001111 ; Strip lower 4 bits for LCD number selection. staa LCDSEL ; ; The 4 upper bits are LCD row data. ; LCD rows clear: 0x20, 0x24, 0x28, 0x2C, 0x30, 0x34, 0x38, 0x3C ; LCD rows set : 0x40, 0x44, 0x48, 0x4C, 0x50, 0x54, 0x58, 0x5C tba ; Bits 4 and 5 for the lower 4 row bits. anda #%00110000 lsra lsra ; Bits 6 and 7 for the upper 4 row bits. lsrb lsrb lsrb lsrb lsrb lsrb incb incb aslb aslb aslb aslb aba ; Add A and B... tab ; ...Store row data in B for use later. ; ; Read second serial byte, the LCD column (0x80->0xA7, 0xC0->0xE7). poll2 tst TRCSR bpl poll2 ldaa RDR beq poll1 ; If byte is 0 then go back, this can be used to sync. ; ; Column staa LCDDAT bsycol tst GATEB bpl bsycol ldx LCDDAT ; Clock LCD SCK signal 4 times. ldx LCDDAT ldx LCDDAT ldx LCDDAT ; ; Row stab LCDDAT bsyrow tst GATEB bpl bsyrow ldx LCDDAT ; Clock LCD SCK signal 4 times. ldx LCDDAT ldx LCDDAT ldx LCDDAT ; ; Repeat and go back to first byte. jmp poll1
Here is the C code:
#include <stdlib.h> #include <stdio.h> #include <stdint.h> #include <stdbool.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <fcntl.h> #include <termios.h> #include <time.h> #include <limits.h> static bool image[32][120]; static bool prev_image[32][120]; static int image_read(const char *path) { FILE *fh; char line[128]; int c; int x; int y; fh = fopen(path, "rb"); if (fh == NULL) { fprintf(stderr, "fopen() failed with errno: %d\n", errno); return -1; } fgets(line, sizeof(line), fh); /* Header */ if (line[0] != 'P' && line[1] != '4') { fprintf(stderr, "Not a binary PBM image!\n"); return -1; } reread: fgets(line, sizeof(line), fh); /* Additional Headers */ if (line[0] == '#') { goto reread; } if (strncmp(line, "120 32", 6) != 0) { fprintf(stderr, "Dimensions must be 120x32!\n"); return -1; } x = 0; y = 0; while ((c = fgetc(fh)) != EOF) { image[y][x] = (c >> 7) & 1; image[y][x+1] = (c >> 6) & 1; image[y][x+2] = (c >> 5) & 1; image[y][x+3] = (c >> 4) & 1; image[y][x+4] = (c >> 3) & 1; image[y][x+5] = (c >> 2) & 1; image[y][x+6] = (c >> 1) & 1; image[y][x+7] = c & 1; x += 8; if (x == 120) { x = 0; y++; if (y == 32) { break; } } } fclose(fh); return 0; } static void image_display(int fd) { uint8_t lcd; uint8_t col; uint8_t row; uint8_t byte; int x; int y; for (lcd = 9; lcd <= 14; lcd++) { for (col = 0x80; col < 0xA8; col++) { for (row = 0; row < 8; row++) { y = ((lcd - 9) / 3) * 16; x = (((lcd - 9) % 3) * 40) + (col - 0x80); if (image[y+row][x] == prev_image[y+row][x]) { continue; } if (image[y+row][x]) { byte = lcd | ((row + 8) << 4); } else { byte = lcd | (row << 4); } write(fd, &byte, 1); write(fd, &col, 1); } } for (col = 0xC0; col < 0xE8; col++) { for (row = 0; row < 8; row++) { y = (((lcd - 9) / 3) * 16) + 8; x = (((lcd - 9) % 3) * 40) + (col - 0xC0); if (image[y+row][x] == prev_image[y+row][x]) { continue; } if (image[y+row][x]) { byte = lcd | ((row + 8) << 4); } else { byte = lcd | (row << 4); } write(fd, &byte, 1); write(fd, &col, 1); } } } memcpy(prev_image, image, sizeof(bool) * 32 * 120); } int main(int argc, char *argv[]) { struct termios tios; int fd; uint8_t byte; struct timespec ts; uint64_t now; uint64_t target; int image_no; char image_path[PATH_MAX]; if (argc != 3) { fprintf(stderr, "Usage: %s <tty> <directory>\n", argv[0]); return 1; } fd = open(argv[1], O_RDWR | O_NOCTTY); if (fd == -1) { fprintf(stderr, "open() failed with errno: %d\n", errno); return 1; } if (tcgetattr(fd, &tios) == -1) { fprintf(stderr, "tcgetattr() failed with errno: %d\n", errno); close(fd); return 1; } cfmakeraw(&tios); cfsetispeed(&tios, B38400); cfsetospeed(&tios, B38400); if (tcsetattr(fd, TCSANOW, &tios) == -1) { fprintf(stderr, "tcsetattr() failed with errno: %d\n", errno); close(fd); return 1; } /* Sync: */ byte = 0; write(fd, &byte, 1); write(fd, &byte, 1); /* Present a blank image first to clear the screen: */ memset(image, false, sizeof(bool) * 32 * 120); memset(prev_image, true, sizeof(bool) * 32 * 120); clock_gettime(CLOCK_MONOTONIC, &ts); target = ((uint32_t)ts.tv_sec * 1000000000L) + ts.tv_nsec; target += 41666000L; image_no = 0; while (1) { /* Display it: */ image_display(fd); image_no++; /* Sync: */ byte = 0; write(fd, &byte, 1); write(fd, &byte, 1); /* Load next image: */ snprintf(image_path, sizeof(image_path), "%s/out%04d.pbm", argv[2], image_no); fprintf(stderr, "[%s]", image_path); if (image_read(image_path) != 0) { break; } /* Wait for one frame: 41666us */ clock_gettime(CLOCK_MONOTONIC, &ts); now = ((uint32_t)ts.tv_sec * 1000000000L) + ts.tv_nsec; while (now < target) { fprintf(stderr, "."); usleep(1000); clock_gettime(CLOCK_MONOTONIC, &ts); now = ((uint32_t)ts.tv_sec * 1000000000L) + ts.tv_nsec; } fprintf(stderr, "\n"); /* Set next target further ahead: */ while (now > target) { target += 41666000L; } } close(fd); return 0; }
And there is a Makefile to conveniently build both:
all: hx20video.s68 hx20video hx20video.bin: hx20video.asm dasm $^ -o$@ -f3 -l$(basename $^).lst hx20video.s68: hx20video.bin srec_cat hx20video.bin -binary -offset 0x1000 -o hx20video.s68 -motorola hx20video: hx20video.c gcc -o hx20video hx20video.c -Wall -Wextra .PHONY: clean clean: rm -f hx20video *.s68 *.bin *.lst
The machine code produced for the HX-20 is meant to be run from RAM and the S-record can be easily loaded into the MONITOR with the hex20 emulator. In hex20 it is possible to save a program to a WAV file which can be played into the MONITOR a real HX-20 from the external cassette interface.
Here is a YouTube demo video of a silhouette animation that was converted.