YASS-Yet Another Spectrum Simulator

Hazte un Spectrum - Capítulo 3

Publicado originalmente: 29 de Agosto de 2012

En las dos primeras partes de éste tutorial ha quedado definida una arquitectura de un procesador Z80 simulado y de los componentes que permiten la conexión de nuestro procesador a diversos componentes a través de buses. Hoy empezamos a construir el componente hardware que convierte nuestra plataforma virtual en un Spectrum: el chip ULA.

ulaEl chip ULA del Spectrum es lo que da la “personalidad” de la máquina: define cómo se gestionan los gráficos, cómo se genera la señal de vídeo que vemos en el monitor (perdón… “televisor”), cómo se genera el sonido, cómo leer el teclado… Esta es, con diferencia, la parte que más me atraía de éste apasionante rato de aprendizaje.

Para empezar, lo primero es hacer un poco de memoria y recordar cómo estaba construido un Spectrum. En la época era impensable diseñar algo ni remotamente parecido a lo que hoy se considera una estructura de ordenador “moderna”: una cpu que habla con un chip “northbridge” que se encarga de redistribuir las señales a los distintos componentes del hardware, ajustando sus diferentes velocidades, anchos de bus y, a ser posible, sin atascar al procesador esperando a que dispositivos lentos terminen su trabajo.

En la época del Spectrum tuvieron una idea económica, sencilla, y facilísima de implementar: en lugar de emplear un costosísimo (y complejísimo para la época) chip que se encargase de éstas funciones, lo que hicieron fue unir directamente y sin escalas el procesador principal y el chip ULA del Spectrum al mismo bus. El problema es que el chip ULA tiene que compartir la memoria con el procesador principal, para leer sus contenidos y generar la imagen que vemos en el televisor. Cómo podríamos hacer que dos componentes hardware activos, que necesitan leer la memoria en el mismo momento, puedan convivir usando los mismos buses?

ZXHardware

En el diagrama (escandalosamente simplificado) se puede ver cómo el chip ULA está conectado directamente al primer banco de memoria de 16KB (y único en el caso del Spectrum 16KB). El chip ULA necesita leer la memoria para generar la imagen de vídeo, pero al mismo tiempo la cpu debe acceder a la memoria también para seguir ejecutando programas. La solución es simple: la cpu Z80 tiene una señal hardware (“WAIT”) que normalmente se emplea por dispositivos hardware “lentos” para permitirles terminar su trabajo cuando son accedidos por el procesador. En éste caso, la señal WAIT está conectada al chip ULA que, cuando necesita acceder a la memoria RAM (o cuando la cpu accede a sus registros) simplemente mantiene activa la señal, haciendo que la cpu tenga que esperar. La conexión del bus de datos del bloque de RAM de 16KB y el procesador está separado por simples resistencias, así que si la cpu lee memoria fuera del rango de la ULA las señales de los chips de memoria llegarán directamente a la CPU (las resistencias bloquean el paso de las señales al bus de la ULA). Cuando la cpu lee los 16KB inferiores y dado que ningún chip de memoria en el bus principal forzará señal alguna en el bus, llegará al bloque de memoria a través de las resistencias. Francamente muy ingenioso (y económico de implementar, por cierto!!).

EmuHardwarePara nuestra implementación vamos a intentar hacer algo parecido, pero en nuestro caso, y dado que el software nos permite emular el hardware “grátis”, sí vamos a emplear el concepto de “northbridge” de una cpu moderna: vamos a implementar una ULA que contiene su propio bloque de 16KB y a los que la cpu accede a través de la misma, independientemente de otros componentes (32KB extra, por ejemplo) que pueda haber conectados. Esta arquitectura nos facilita otra emulación que vendrá en un futuro: emular los retardos que introduce la ULA en la ejecución de programas – y os sorprendería la cantidad de software que “cuenta ciclos” para realizar curiosos efectos gráficos.

ULA – implementación básica

Según podemos ver en el gráfico, el chip ULA se conecta al mismo tiempo tanto al bus de datos como al bus de I/O del procesador. Hasta ahora nuestros componentes (como por ejemplo la RAM) podían conectarse a uno u a otro, pero no a los dos a la vez. Tipo de bus nuevo? Nuevas clases de dispositivos? O cómo hacemos para que un componente herede de dos clases bases iguales a la vez? Nuevamente, C++ viene al rescate: herencia múltiple.

Necesitamos que nuestro componente ULA distinga entre los accesos al bus de datos y accesos al bus de direcciones, pero la cpu usa la misma función en ambos casos: Read() o Write() de las clases base BusComponentBase.  Podríamos pensar en que la hemos pifiado con el diseño original, pero no es el caso: implementar la ULA derivando de dos buses a la vez es tan “simple” como ésto:

class ULAMemory : public RAM<16384,16*1024>
{
protected:
  void Write(unsigned int address,unsigned char value)
  {
    // First, store data
    __super::Write(address,value);
    // and forward to ULA
    MemoryWrite(address,value);
  }
  virtual void MemoryWrite(unsigned int address,
                           unsigned char value) = 0;
};

class ULAIO : public BusComponent<0xFE,1>
{
protected:
  unsigned char Read(unsigned int address)
  {
    return(IORead(address));
  }

  void Write(unsigned int address,unsigned char value)
  {
    IOWrite(address,value);
  }

  virtual unsigned char IORead(unsigned int address) = 0;
  virtual void IOWrite(unsigned int address,
                       unsigned char value) = 0;
};

class ULA : public ULAMemory, ULAIO
{
   ....
   void MemoryWrite(unsigned int address,unsigned char value);
   void IOWrite(unsigned int address,unsigned char value);
   unsigned char IORead(unsigned int address);
   ....
};

Definimos dos clases base (virtuales) que cuando reciben las llamadas desde la cpu Read() y Write() invocan a una correspondiente función MemoryWrite(), IORead() e IOWrite() de una futura clase derivada, implementada por la clase ULA, que deriva de ambas. En éste caso no implementamos MemoryRead(), porque la implementación base (de la clase RAM) hace precisamente lo que necesitamos: devolver el contenido de la posición de memoria emulada a la cpu. En cambio la función MemoryWrite() la emplearemos para actualizar la pantalla simulada. Para más efectividad, la clase ULAMemory deriva de RAM<16384,16384>, con lo que el almacenamiento de la ULA ya está implementado. No ha sido tan difícil, verdad?

Pero cómo conectamos cada uno de los dos buses de la ULA a los buses correspondientes del procesador? Fácil, casteando la instancia de la ULA al bus correspondiente:

   dataBus.AddBusComponent((ULAMemory*)&ula);
   ....
   ioBus.AddBusComponent((ULAIO*)&ula);

La pantalla del Spectrum

Ya que estamos rehaciendo la ULA, el primer paso será analizar la pantalla del Spectrum y analizar su emulación.

El Spectrum tenía una pantalla de 256x192 píxeles (increíble que se viese algo, verdad?) y emplea dos zonas de memoria para la generación de la imagen. La primera zona de memoria de 6 kilobytes, comprendida entre las posiciones de memoria 16384 (0x4000) a 22527 (0x57FF) contiene el bitmap en sí, donde cada byte define una secuencia de 8 píxeles horizontales. Una segunda sección inmediatamente después de 768 bytes, desde 22528 (0x5800) a 23295 (0x5AFF) contiene un byte de atributo por cada bloque de 8x8 píxeles del bitmap. Cada byte de atributos, que se aplica a cada bloque de 8x8 pixeles en pantalla, tiene el formato siguiente:AttrFormat

El bit marcado como ‘F’ indica que el carácter parpadea, esto es, intercambia cada cierto tiempo los colores de fondo y principal. Los bits ‘P’ indican el color de fondo (o “PAPER” usando nomenclatura del Spectrum), Los bits ‘I’ a su vez indican el color principal (“INK”) del carácter, y el bit ‘B’ indica que el color es brillante.

Parece fácil, no? Bueno, casi.

El “problema” es que, internamente, la líneas que componen el bitmap de pantalla del Spectrum no están ordenados. De hecho, el truco consiste en que “cruzaron” líneas de direcciones entre los bloques superior e inferior de direcciones, de tal forma que podían usar la parte alta de los registros de 16 bits (H en el registro HL, por ejemplo) para ir “bajando” líneas a medida que dibujaban caracteres. Para que ésto funcione, cada línea tiene que estar 256 bytes más adelante en memoria, en lugar de los 32 (32 bytes x 8 pixeles son los 192 pixeles horizontales de pantalla) que serían necesarios de usar un bitmap correlativo. Se ahorran un montón de cálculos, porque para avanzar líneas basta con hacer “INC H” en lugar de otras operaciones aritméticas.

ScreenVidConversionDado que vamos a emular la pantalla en un bitmap de 320x240 (tamaño francamente estándar y que además nos da margen para emular el borde de la pantalla), era necesario “linealizar” la memoria de vídeo del Spectrum. Resulta que es tan fácil como intercambiar dos bloques de tres bits en la dirección para obtener otra dirección, lineal en un bitmap de 256x192.

Como tenía que definir el formato en que iba a general el bitmap, y para que fuese lo más portable posible, opté por emplear BGRA en 32 bits. Lo mejor del caso es que, dado que los colores que puede generar la ULA del Spectrum son limitados a 8 (con otros 8 iguales pero más brillantes) y lo mejor es hacerlo por tabla, adaptar de un formato a otro es trivial en la mayoría de los casos. Así que manos a la obra e incluímos el siguiente código en la función MemoryWrite() de la clase ULA:

  void MemoryWrite(unsigned int address,unsigned char value)
  {
    if (nativeBitmap == NULL)
      return;

    address &= 0x3FFF;
    if (address > 0x1AFF)   // Above bitmap+attrs?
      return;

    // Video memory: 0x0000->0x17FF bitmap graphics,
    //               0x1800->0x1AFF attributes
    if (address < 0x1800)   // Bitmap graphics
    {
      // Convert from Spectrum memory video to plain buffer
      unsigned int rowbase = address >> 5;
      rowbase = ((rowbase & 0xC0) |
      ((rowbase & 0x38) >> 3) |
      ((rowbase & 0x07) << 3));

      LPDWORD scr = nativeBitmap;
      // Center pixel video (256x192) in the whole
      //bitmap (320x240) == offset 32 horz, 24 vert
      scr += (320 * 24);
      scr += 32;
      scr += (rowbase * dwordsPerRow);
      scr += ((address & 0x001F) * 8);

      // Fetch attribute
      unsigned char attr = data[0x1800 + ((rowbase / 8) * 32) + (address & 0x001F)];
      DWORD dwInk;
      DWORD dwPaper;
      if ((attr & 0x80) && blinkState)
      {
        dwPaper = dwColorTable[((attr & 0x40) >> 3) | (attr & 0x07)];
        dwInk = dwColorTable[(attr & 0x78) >> 3];
      }
      else
      {
        dwInk = dwColorTable[((attr & 0x40) >> 3) | (attr & 0x07)];
        dwPaper = dwColorTable[(attr & 0x78) >> 3];
      }

      for (int dd=7;dd >= 0;dd--)
      {
        *scr++ = (value & (1 << dd)) ? dwInk : dwPaper;
      }
      IsDirty = true;
    }
    else    // Attribute memory
    {
      // ToDo: Redraw whole affected "character block"
    }
  }

La variable miembro memoryBitmap se inicializará (por otros medios) al buffer real que hay que manipular y que nos será provisto desde una instancia superior. En la clase está definido como un DWORD*, para poder acceder a cada pixel BGRA (32 bits) como un solo elemento del array. La función accede al miembro data de la clase base ULAMemory para determinar el atributo (los colores) a emplear para “pintar” la pantalla. Y finalmente, la variable blinkState determina en qué fase del “parpadeo” se encuentra el carácter y que será actualizada más adelante (a “0” ó “1” alternativamente).

La cosa se complica un poco cuando lo que se escribe en la memoria de la ULA es un atributo de color: hay que actualizar un carácter completo de 8x8 píxeles, así que vamos a escribir una función especializada en ello. La función simplemente recibe el offset del carácter a redibujar, de 0 a 767:

void UpdateChar(unsigned int nChar)
{
  int memOffset = ((nChar & 0x300) << 3) | (nChar & 0xFF);
  unsigned char value = data[nChar + 0x1800];

  LPDWORD scr = nativeBitmap;
  // Center pixel video (256x192) in the whole
  // bitmap (320x240) == offset 32 horz, 24 vert
  scr += (320 * 24);
  scr += 32;
  scr += (((nChar / 32) * 8) * dwordsPerRow);
  scr += ((nChar & 0x001F) * 8);

  DWORD dwInk;
  DWORD dwPaper;
  if ((value & 0x80) && blinkState)
  {
    dwPaper = dwColorTable[((value & 0x40) >> 3) | (value & 0x07)];
    dwInk = dwColorTable[(value & 0x78) >> 3];
  }
  else
  {
    dwInk = dwColorTable[((value & 0x40) >> 3) | (value & 0x07)];
    dwPaper = dwColorTable[(value & 0x78) >> 3];
  }

  // Redraw 8x8 pixels
  for (int yy=0;yy<8;yy++)
  {
    unsigned char scanData = data[memOffset];
    LPDWORD pixel = scr;
    for (int dd=7;dd >= 0;dd--)
      *pixel++ = (scanData & (1 << dd)) ? dwInk : dwPaper;
    memOffset += 256;   // Next scanline on Spectrums memory
    scr += dwordsPerRow; // Next scanline on native bitmap
  }
}

Ahora podemos completar la función MemoryWrite(), para que actualice el bloque de 8x8 píxeles cuando se cambia un atributo:

    else    // Attribute memory
    {
      // Redraw whole affected "character block"
      UpdateChar(address - 0x1800);
    }

Finalmente, sólo nos queda definir la paleta de colores (dwColorTable) del Spectrum:

static const unsigned int dwColorTable[] =
{
  0xFF000000,
  0xFF0000CD,
  0xFFCD0000,
  0xFFCD00CD,
  0xFF00CD00,
  0xFF00CDCD,
  0xFFCDCD00,
  0xFFCDCDCD,

  0xFF000000,
  0xFF0000FF,
  0xFFFF0000,
  0xFFFF00FF,
  0xFF00FF00,
  0xFF00FFFF,
  0xFFFFFF00,
  0xFFFFFFFF
};

El siguiente paso

Qué sigue ahora? Ahora toca hacer un banco de pruebas para todos estos componentes, y verificar que podemos traer un Spectrum a la vida. Para hacernos la vida más facil vamos a usar un aliado que nos hará casi, casi trivial la implementación de una aplicación que una todas las piezas que hemos desarrollado: WPF. Y para facilitar la unión de un entorno de tan alto nivel y nuestros componentes en C++, nada mejor que seguir desarrollando en C++… pero gestionado y soportado por .NET.