YASS-Yet Another Spectrum Simulator

Hazte un Spectrum - Capítulo 5

Publicado originalmente: 4 de Septiembre de 2012

En el capítulo anterior conseguimos arrancar por primera vez nuestro nuevo emulador de Spectrum, pero sin ninguna forma de recibir entradas del mundo exterior su utilidad es francamente nula. Hoy implementaremos la simulación del teclado y las temporizaciones de la máquina.

La implementación del teclado tiene dos partes realmente: la primera, el interface hardware entre la cpu y las teclas, misión encargada (obviamente) al chip ULA. Por otra parte, el Spectrum necesita un reloj que periódicamente le haga “visitar” el teclado y leerlo. Todos los ordenadores necesitan este tipo de relojes y la mayoría incluyen relojes internos programables que facilitan la tarea. En los ‘80 éste no era el caso y la mayoría empleaban la temporización de la pantalla para sincronizar sus operaciones. Y cuando digo “pantalla” me refiero a “televisor” y su conocido refresco de 50 cuadros por segundo.

El chip ULA del Spectrum es el responsable (como ya hemos visto) de generar la imagen que aparece en la pantalla. Para hacerlo, el chip ULA cuenta meticulosamente ciclos de la máquina para determinar dónde está el cañón del televisor en cada momento. Durante unas cuantas líneas (el borde superior de la pantalla) el chip simplemente genera el color del borde y periódicamente la señal de sincronismo horizontal para que el cañón vuelva al flanco izquierdo de la pantalla. Cuando la ULA determina que ha llegado a la zona de pantalla propiamente dicha, comienza a leer la memoria de vídeo y convertir los contenidos en imágenes durante 256 píxeles, luego genera un poco más de borde, un sincronismo horizontal, un poco más de borde (en el lado izquierdo) y luego otra línea de pantalla, así hasta que completa las 192 líneas. FInalmente, vuelve a generar color de borde hasta que se completa el cuadro de pantalla (312 líneas) y genera la señal de sincronismo vertical, para que le haz de electrones del cañón vuelva a la esquina superior izquierda de la pantalla. Y esto, 50 veces por segundo. Es en éste momento cuando el chip ULA envía una señal a la cpu (la señal INT) que interrumpe el proceso de la cpu cada cuadro y que el Spectrum emplea para sincronizar sus procesos.

En nuestra emulación no existe un cañón de electrones de una televisión, ni nada que se le parezca, pero necesitamos simular esa interrupción 50 veces por segundo. Podríamos simularla con un simple temporizador, pero su precisión sería desastrosa. Os recuerdo que muchos juegos del Spectrum hacían simpáticos efectos con el borde de la pantalla (cambiando el color muchas más veces que 50 por segundo). También quiero simular ese efecto.

Afortunadamente, el chip ULA emplea los mismos relojes que la cpu Z80 y los usa para cronometrar sus operaciones. El chip ULA empieza a contar ciclos de reloj y considera que los 16384 primeros ciclos son el borde superior de la pantalla, justo hasta el borde izquierdo del contenido de pantalla de la primera línea de visualización. Luego cada línea son 224 ciclos exactos: 128 ciclos para presentar los 256 píxeles de pantalla (esto es, 2 píxeles por cada ciclo), mas 96 ciclos para el borde derecho, el sincronismo horizontal y el borde izquierdo de la siguiente línea. En total, cada fotograma de pantalla son 224 ciclos por 312 líneas, esto es, 69888 ciclos (o tStates) y si multiplicáis esta cifra por 50 fotogramas por segundo obtenemos 3494400 ciclos (los famosos 3.5 MHz a los que funciona el Spectrum). De hecho, el chip ULA realmente funciona a 3.5 MHz, lo cual resulta en una visualización en pantalla de 50.08 cuadros por segundo… afortunadamente, a ningún televisor le afecta esta diferencia.

Así que vamos a implementar la función de la ULA que cuenta los ciclos de reloj a medida que avanza la visualización, en la función AddCycles():

#define   TSTATES_PER_SCANLINE   (224)
#define   TVSCANLINES            (312)
#define   TSTATES_PER_FRAME      (TSTATES_PER_SCANLINE * TVSCANLINES)

void AddCycles(unsigned int cycles,bool& IRQ)
{
  dwFrameTStates += cycles;
  dwScanLineTStates += cycles;

  if (dwScanLineTStates > TSTATES_PER_SCANLINE)
    ScanLine(IRQ);
}

Empleamos dos contadores porque es más simple que hacer operaciones sobre uno solo, y mantenemos un contador de líneas y un contador de cuadros. Cuando detectamos que se ha completado una línea de visualización completa, invocamos a la función ScanLine():

void ScanLine(bool &IRQ)
{
  dwScanLineTStates %= TSTATES_PER_SCANLINE;
  dwScanLine++;
  if (dwScanLine >= TVSCANLINES)
  {
    // Frame complete - trigger IRQ
    IRQ = true;
    dwFrameTStates %= TSTATES_PER_FRAME;
    dwScanLine = 0;
    if (++dwFrameCount > 16)    // each 16 full frames...
    {
      dwFrameCount = 0;         // reset counter...
      blinkState = !blinkState; // ... invert blink ...
      UpdateBlink();            // ... and update screen contents
    }
  }

  // Check if current scanline (0-311) lies within the "visible portion"
  // of screen bitmap (lines 0-239), i.e., skip first and last 36 lines (front/back porches)
  if ((dwScanLine < 36) || (dwScanLine > (239+36))) return;

  // Now check if current background color for scanline is different
  // from the required one
  DWORD bitmapLine = dwScanLine-36;
  if (dwCurrentScanLineBackColor[bitmapLine] == dwBorderRGBColor)  // Already right color
    return;

  // Redraw current scanline with proper color
  LPDWORD scr = nativeBitmap;
  scr += (bitmapLine * 320);	// Get pointer to start of line

  // top/bottom borders?
  if ((bitmapLine < 24) || (bitmapLine > 24+191))
  {
    // Draw whole line
    unsigned int bCount = 320;
    do
    {
      *scr++ = dwBorderRGBColor;
    }while (--bCount);
    }
  else
  // screen contents area
  {
    // First 32 pixels
    unsigned int bCount = 32;
    do
    {
      *scr++ = dwBorderRGBColor;
    } while (--bCount);

    // Last 32 pixels
    scr += 256;
    bCount = 32;
    do
    {
      *scr++ = dwBorderRGBColor;
    } while (--bCount);
  }

  // and remember this scanline's colour
  dwCurrentScanLineBackColor[bitmapLine] = dwBorderRGBColor;
  IsDirty = true;
}

La función realiza tres operaciones: cuenta los ciclos de reloj tanto para la línea en curso como para la pantalla completa, y si determina que ésta se ha dibujado totalmente indica que es necesario invocar la interrupción del procesador poniendo a verdadero la variable IRQ. También cuenta bloques de 16 fotogramas para hacer parpadear los caracteres de pantalla que tengan el atributo “blink”. Por último la función comprueba si ha cambiado el color del borde y repinta cada línea de la pantalla (si es que ha cambiado) con el nuevo color.

Para añadir la interrupción, basta un pequeño cambio en la función emulatorMain() que ya vimos en un capítulo anterior:

    DWORD dwFrameStartTime = GetTickCount();
    do
    {
      // Emulate next instruction
      cpu.tStates = 0;
      cpu.EmulateOne();

      // After each instruction, report the ULA the number of cycles we've used
      bool irq = false;
      ula.AddCycles(cpu.tStates,irq);

      // As in the real Spectrum, the ULA will trigger an IRQ for every frame. This
      // implementation uses cpu clock cycles to know where the screen beam is.
      if (irq)		// Ula signals a frame interrupt
      {
        cpu.INT();		// Generate system interrupt

        // If screen contents have been modified, set a flag for the WPF rendering event.
        if (ula.IsDirty)
        {
          screenDataDirty = true;
          ula.IsDirty = false;
        }

        // The PC executes code a lot faster than the original Z80.
        // As we now that "20ms" (1 frame) have ellapsed, pause execution to
        // match host PC and emulated computer
        DWORD dwNow = GetTickCount();
        DWORD dwEllapsed = dwNow - dwFrameStartTime;
        if (dwEllapsed < 20)    // Running above real time?
        {
          Sleep(20 - dwEllapsed);
          dwNow = GetTickCount();
        }
        dwFrameStartTime = dwNow;
      }
    } while(quitEmulation == false);

Utilizando el contador de ciclos de la clase Z80 vamos actualizando la ULA para que mantenga la posición de pantalla. Si AddCycles() activa la variable local que le pasamos como referencia (irq), simplemente invocamos el método INT() del Z80 para que dispare una interrupción. Fijaos que también podemos aprovecharnos de esta característica para hacer que nuestro emulador corra más o menos a la misma velocidad que un Spectrum: dado que sabemos que la máquina ha estado corriendo durante un cuadro de pantalla completo (1/50 de segundo = 20 milisegundos), y que nosotros sabemos cuanto tiempo hemos empleado en hacerlo (dwFrameStartTime – dwNow), basta con hacer un Sleep() del tiempo restante hasta cumplir los 20 milisegundos. No es exacto, pero el efecto es muy, muy aproximado.

Finalmente, añadimos una variable (screenDataDirty) para saber si los contenidos de pantalla han cambiado o no y optimizar la función de refresco de WPF (y no repintar cada fotograma completo como hasta ahora):

void Spectrum::OnRenderTick(Object^ sender,EventArgs ^e)
{
  if (screenDataDirty)
  {
    emulatorScreen->Lock();
    emulatorScreen->AddDirtyRect(Int32Rect(0,0,320,240));
    screenDataDirty = false;
    emulatorScreen->Unlock();
  }
}

Ya tenemos interrupciones! Ahora viene la parte en la que emulamos el hardware de teclado de la ULA.

8 ó 16 bits?

Al que se haya fijado, comprobará como el bus de I/O que hemos definido para nuestro procesador Z80 es de 16 bits. En cambio, y según la documentación de Zilog, el bus de direcciones de I/O del procesador real es de sólo 8 bits. Y esto? Bueno, no tengo documentación de la época y afortunadamente el datasheet actualizado lo describe, pero es que realmente cuando el procesador hace una operación de I/O coloca direcciones en los 16 bits de direcciones, aprovechando el contenido de otros registros del procesador.

Cuando leemos un puerto con una instrucción como IN A,(C), el procesador realmente coloca en el bus de direcciones el contenido completo (16 bits) del registro BC, y no sólo la parte baja del mismo (C) como cabría esperar. De igual forma, cuando hacemos un IN A,#puerto, el procesador tiene la necesidad de colocar “algo” en los 8 bits más significativos del bus de direcciones, y coloca los contenidos del acumulador (registro A) porque es lo que tiene más a mano. Por ejemplo, las instrucciones LD A,#14; IN A,$FE realmente leen el puerto de I/O $14FE. Y lo más curioso es que el Spectrum hace uso de esta característica para leer el teclado.

El chip ULA sólo utiliza una línea de direcciones para saber si están hablando con él: el bit de dirección 0 del bus. Esto es, cualquier dirección par de I/O accede a la ULA. Por convención, siempre se usa la dirección 0xFE, pero cualquier dirección par obtiene el mismo resultado. Y para leer el teclado aprovecha los 8 bits superiores de la dirección de I/O para saber qué sección del teclado deseamos leer.

El teclado del Spectrum

Ahora veamos cómo se conecta el teclado al resto del sistema. Las 40 teclas del Spectrum (4 filas de 10 teclas) están divididas en 8 bloques de cinco teclas cada uno:SpectrumKeyboard

Para leer una fila del teclado, hay que leer el puerto de I/O de la ULA (0xFE) colocando en el byte más significativo la fila en la que estamos interesados. La fila se codifica poniendo a “cero” el bit que represente su posición. Por ejemplo, para leer la fila “0” (teclas Shift, Z, X, C y V) se coloca “11111110” (0xFE), mientras que para leer la fila “5” (con las teclas Y, U, I, O y P) se coloca “11011111” (o lo que es lo mismo: 0xDF) en la parte alta de la dirección del puerto. Tras cada lectura, el chip ULA devuelve el estado de las cinco teclas correspondientes a esa fila (los cinco bits menos significativos) estando a cero el bit que represente una tecla pulsada. Fijaos también que la ordenación de los bits de las semilíneas 0 a 3 es la inversa que para las semifilas 4 a 7.

Para la implementación del teclado del emulador, voy a seguir el mismo patrón: un array de 8 bytes que representa las ocho “semifilas” de teclas y que podremos manipular desde el exterior para simular las pulsaciones:

protected:
  unsigned char keyMatrix[8];

public:
  void PressKey(unsigned int keyRow,unsigned int keyCol,bool down)
  {
    if (keyCol > 9)
      return;
    if (keyRow > 3)
      return;

    // Spectrum keyboard layout
    //       D0 D1 D2 D3 D4    D4 D3 D2 D1 D0
    // A11      ROW3              ROW4          A12
    // A10      ROW2              ROW5          A13
    //  A9      ROW1              ROW6          A14
    //  A8      ROW0              ROW7          A15

    int rowNdx;
    int bitMask;
    if (keyCol < 5)   // Left bank
    {
      rowNdx = 3 - keyRow;
      bitMask = 0x01 << keyCol;
    }
    else              // Right bank
    {
      rowNdx = 4 + keyRow;
      bitMask = 0x01 << (9 - keyCol);
    }

    if (down)
      keyMatrix[rowNdx] |= bitMask;
    else
      keyMatrix[rowNdx] &= ~bitMask;
  }

La función recibe las coordenadas de una tecla en el formato físico del Spectrum como “fila/columna”, y modifica el bit correspondiente a la configuración física por medio de unas simples operaciones, dependiendo de si la tecla ha sido pulsada (down == TRUE) o liberada (down == FALSE). El array keyMatrix queda totalmente configurado para poder leerlo muy rápidamente (y con la misma facilidad que en el caso real) cuando el código del Spectrum lo necesite.

Las peticiones de lectura del teclado llegarán a la ULA desde el procesador por medio de la función IORead(), que hasta ahora no hacía nada salvo devolver 0xFF (valor habitual en buses vacíos):

  unsigned char IORead(unsigned int address)
  {
    // ULA is selected by A0 being low
    if ((address & 0x01) == 0)
    {
      // Get the scancodes
      unsigned char kData = 0xFF;   // Pull ups
      unsigned char row = (address >> 8) ^ 0xFF;
      for (int dd=0;dd<8;dd++)
      {
        if (row & (1 << dd))        // Select scanline?
          kData &= ~keyMatrix[dd];  // pull down bits representing "pressed" lines
      }
      return(kData);
    }
    return(0xFF);
  }

La función decodifica la dirección de I/O de la misma forma que el chip ULA real: viendo si el bit menos significativo de la dirección es “0”. En tal caso, recorre el array de filas, comprobando si la posición coincide con la máscara requerida. Si es así, limpia los bits correspondientes a las teclas pulsadas en la fila. La función recorre las ocho filas de teclas: si se solicita un identificador de fila con varios bits a cero, simplemente combina las teclas pulsadas en todas las filas seleccionadas.

Publicando el API del teclado

Dado que nuestro emulador de Spectrum es un control, necesitamos publicar funciones que nos permitan interactuar con él desde nuestra aplicación host. Dado que la instancia de la clase ULA es interna a la función emulatorMain() (y que hace todo el trabajo), vamos a publicar un puntero en nuestra clase que nos permita acceder a la instancia desde el exterior. Nada más simple: en la declaración de la clase Spectrum añadimos lo siguiente:

private:
  ULA *pCurrentUla;

y en la función emulatorMain()

void Spectrum::emulatorMain()
{
  // Components
  ...  
  ULA ula;     // Spectrum's ULA chip + 16KB
  ...

  pCurrentUla = &ula;
  ...

Cuando la emulación termina la función limpia la variable. Ahora, nada más fácil ahora que implementar dos funciones públicas (PressKey() y ReleaseKey()) que nos permitan simular las pulsaciones de teclas desde la aplicación host:

public:
  void Spectrum::PressKey(int keyRow,int keyCol)
  {
    if (pCurrentUla != nullptr)
      pCurrentUla->PressKey(keyRow,keyCol,true);
  }

  void Spectrum::ReleaseKey(int keyRow,int keyCol)
  {
    if (pCurrentUla != nullptr)
      pCurrentUla->PressKey(keyRow,keyCol,false);
  }

Terminemos añadiendo la emulación de teclado a nuestra aplicación host en C#. Sobrecargando las funciones OnKeyDown() y OnKeyUp() de la clase base, podemos pulsar o liberar las teclas del Spectrum. Aquí sigue un fragmento de la función OnKeyDown() – como podréis imaginar la función OnKeyUp() es básicamente la misma, solo que invocando a la función ReleaseKey() del control:

protected override void OnKeyDown(System.Windows.Input.KeyEventArgs e)
{
   base.OnKeyDown(e);
   switch (e.Key)
   {
     .....
     case System.Windows.Input.Key.Q: emulator.PressKey(1, 0); break;
     case System.Windows.Input.Key.W: emulator.PressKey(1, 1); break;
     case System.Windows.Input.Key.E: emulator.PressKey(1, 2); break;
     case System.Windows.Input.Key.R: emulator.PressKey(1, 3); break;
     case System.Windows.Input.Key.T: emulator.PressKey(1, 4); break;
     case System.Windows.Input.Key.Y: emulator.PressKey(1, 5); break;
     case System.Windows.Input.Key.U: emulator.PressKey(1, 6); break;
     case System.Windows.Input.Key.I: emulator.PressKey(1, 7); break;
     case System.Windows.Input.Key.O: emulator.PressKey(1, 8); break;
     case System.Windows.Input.Key.P: emulator.PressKey(1, 9); break;
     .....

Podemos aprovechar también para fabricarnos “atajos” en el teclado del Spectrum. Por ejemplo, para borrar un carácter (tecla “Delete” del Spectrum) es necesario pulsar la combinación “mayúsculas” y “0”. Podemos mapear la tecla del PC a una combinación de ambas ( y recordad hacer lo mismo en la función OnKeyUp()!!)

     // Keyboard shortcuts:
     case System.Windows.Input.Key.Back:
       emulator.PressKey(3, 0);      // Shift
       emulator.PressKey(0, 9);      // '0'
       break;

Ya podemos escribir!

SpectrumWKeyboard

Gracias a todo lo visto hoy nuestro control Spectrum está casi listo – ya podemos iniciar de nuevo nuestro emulador y disfrutar escribiendo código como se hacía en los ‘80… Quien no ha escrito algo así? Es lo primero que hicimos cuando, de chavales, descubríamos el maravilloso mundo de la informática, los ordenadores, la programación… y los videojuegos!

En la próxima entrega de este tutorial implementaremos la simulación de una unidad de cinta de cassette… porque todos queremos jugar a Head Over Heels o Skooldaze, no?