Kjetil's Information Center: A Blog About My Projects

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.

Topic: Scripts and Code, by Kjetil @ 07/07-2023, Article Link