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.