YASS-Yet Another Spectrum Simulator

Hazte un Spectrum - Capítulo 7

Publicado originalmente: 7 de Septiembre de 2012

Ya casi llegamos al final y hoy vamos a añadir dos componentes a nuestro emulador: la capacidad de hacer “ruido” y la posibilidad de emular un joystick Kempston.

Y para comenzar, vamos con lo facilito primero: el joystick. El hardware de un joystick Kempston es lo más simple del mundo: solamente un registro de I/O que se direcciona poniendo el bit de dirección 5 a cero – muy parecido al direccionamiento de un bit de la ULA del Spectrum. Al leer el registro (típicamente de 0x1F == 00011111b por ejemplo) se obtiene un byte donde los cinco bits menos significativos son el estado de las cinco posiciones del joystick (arriba, abajo, izquierda, derecha y “fuego”):

KempstonBits

Dado que hasta ahora estábamos conectado la ULA directamente al bus de I/O de la cpu Z80, ya no puede ser el caso, y necesitamos un decodificador intermedio. La clase hospeda los componentes (ULA y Kempston), decodifica la dirección y reenvía la petición al controlador apropiado:

class SpectrumIOBus : public BusComponent<0x00,0x10000>
{
public:
  SpectrumIOBus()
  {
    ULA = NULL;
    Kempston = NULL;
  }

  unsigned char Read(unsigned int address)
  {
    // Kempston interface is addressed by A5 == 0
    if (Kempston && ((address & 0x20) == 0))
      return(Kempston->Read(address));

    // ULA is addressed by A0 == 0
    if (ULA && ((address & 0x01) == 0))
    return(ULA->Read(address));

    // No device
    return(0xFF);
  }

  void Write(unsigned int address,unsigned char value)
  {
    // ULA is addressed by A0 == 0
    if (ULA && ((address & 0x01) == 0))
      return(ULA->Write(address,value));
  }

public:
  BusComponentBase *ULA;
  BusComponentBase *Kempston;
};

Si os fijáis, en el Write() del bus ni siquiera nos molestamos en enviar las escrituras al controlador Kempston…

La implementación de la clase Kempston es igual de simple:

#define KEMPSTON_RIGHT        0x01
#define KEMPSTON_LEFT         0x02
#define KEMPSTON_DOWN         0x04
#define KEMPSTON_UP           0x08
#define KEMPSTON_FIRE         0x10

class Kempston : public BusComponent<0x1F,1>
{
public:
  Kempston()
  {
    kempstonData = 0;
  }

public:
  void SetKempstonData(unsigned char data)
  {
    kempstonData = data;
  }

public:
  unsigned char Read(unsigned int address)
  {
    return(kempstonData);
  }

  void Write(unsigned int address,unsigned char value)
  {
    // Do nothing
  }

  unsigned char kempstonData;
};

La variable miembro kempstonData (pública) es donde la aplicación host establece el estado del joystick, y la clase simplemente conserva el valor y lo devuelve a la cpu cada vez que nos lo pida. Finalmente, en la clase Spectrum vamos a modificar los buses de nuestro procesador para integrar el nuevo decodificador de I/O y agregar el interface Kempston:

  void Spectrum::emulatorMain()
  {
    // Components
    Bus16 bus;              // Main Z80 data bus
    SpectrumIOBus ioBus;    // Main Z80 I/O bus
    ROM<0,16384> rom;       // Main system ROM
    ULA ula;                // Spectrum's ULA chip + 16KB
    RAM<32768,32*1024> ram; // Remaining RAM (32KB)
    Kempston kempston;      // Kempston joystick
    Z80 cpu;                // and finally, the CPU core

    // Load ROM contents
    if (rom.Load("48.rom"))
      throw gcnew System::IO::FileNotFoundException("Unable to load rom '48.ROM'");

    // Configure the ULA with the native Windows bitmap used to emulate the screen
    ula.SetNativeBitmap((LPBYTE)screenData,bytesPerScreenLine);

    // Populate buses
    bus.AddBusComponent(&rom);
    bus.AddBusComponent((ULAMemory*)&ula);
    bus.AddBusComponent(&ram);

    ioBus.ULA = (ULAIO*)&ula;
    ioBus.Kempston = &kempston;

    // And attach busses to cpu core
    cpu.DataBus = &bus;
    cpu.IOBus = &ioBus;

    pCurrentUla = &ula;
    pCurrentKempston = &kempston;

Con una enumeración de .NET y un par de funciones, tenemos el API para poder usar el joystick desde una aplicación host:

namespace Emulation
{
  // Kempston joystick status values.
  [Flags]
  public enum class KempstonFlags
  {
    Right = KEMPSTON_RIGHT,
    Left = KEMPSTON_LEFT,
    Down = KEMPSTON_DOWN,
    Up = KEMPSTON_UP,
    Fire = KEMPSTON_FIRE
  };

  public ref class Spectrum : System::Windows::Controls::Border, IDisposable
  {
    ...
  public:
    void SetKempstonStatus(KempstonFlags status)
    {
      if (pCurrentKempston != nullptr)
        pCurrentKempston->SetKempstonData((unsigned char)status);
    }

  private:
    ULA *pCurrentUla;
    Kempston *pCurrentKempston;
  }

Sólo queda ampliar nuestra gestión del teclado en C# para mapear las teclas de cursor y “ctrl” (por ejemplo) al joystick Kempston (nuevamente, tanto en OnKeyDown() como en OnKeyUp()):

     Emulation.KempstonFlags kempston;

    protected override void OnKeyDown(System.Windows.Input.KeyEventArgs e)
    {
      base.OnKeyDown(e);
      switch (e.Key)
      {
        ...
        // Kempston
        case System.Windows.Input.Key.Left: kempston |= Emulation.KempstonFlags.Left; break;
        case System.Windows.Input.Key.Right: kempston |= Emulation.KempstonFlags.Right; break;
        case System.Windows.Input.Key.Up: kempston |= Emulation.KempstonFlags.Up; break;
        case System.Windows.Input.Key.Down: kempston |= Emulation.KempstonFlags.Down; break;
        case System.Windows.Input.Key.LeftCtrl: kempston |= Emulation.KempstonFlags.Fire; break;
      }
      emulator.SetKempstonStatus(kempston);
    }

    protected override void OnKeyUp(System.Windows.Input.KeyEventArgs e)
    {
      base.OnKeyUp(e);
      switch (e.Key)
      {
      ...
        // Kempston
        case System.Windows.Input.Key.Left: kempston &= ~Emulation.KempstonFlags.Left; break;
        case System.Windows.Input.Key.Right: kempston &= ~Emulation.KempstonFlags.Right; break;
        case System.Windows.Input.Key.Up: kempston &= ~Emulation.KempstonFlags.Up; break;
        case System.Windows.Input.Key.Down: kempston &= ~Emulation.KempstonFlags.Down; break;
        case System.Windows.Input.Key.LeftCtrl: kempston &= ~Emulation.KempstonFlags.Fire; break;
      }
      emulator.SetKempstonStatus(kempston);
    }

Vamos finalmente con la parte de audio que, lamentablemente, es algo más compleja que la implementación del interface Kempston. Veamos primero un poco de la teoría

Sonido de 1 bit?

Aunque siendo pragmáticos el sonido del Spectrum realmente son “2” bits: uno para manipular el altavoz y otro para la generación de audio para la unidad de cassette, la única diferencia entre ambos es una resistencia para limitar la amplitud de la segunda señal. El audio de “1 bit” sólo permite establecer dos niveles de audio, frente a los 65536 que se pueden definir con (por ejemplo) una moderna tarjeta de sonido de 16 bits. Usando Audacity (un estupendo editor de audio open source) podemos comparar audio de 1 bit con audio de 16 bits:

Audio16vs1

Con una limitación como ésta, parecería imposible que el Spectrum pudiese producir nada más que tristes pitidos, verdad? Pues no: utilizando pulsos escrupulosamente medidos (codificación PWM o “Pulse Width Modulation”) se puede obtener un audio que simule características muy avanzadas: voces múltiples, control de volumen, etc. El truco del audio PWM es que, ya que no puedo “colocar el altavoz en la posición que yo quiero”, lo que sí puedo hacer es dar “empujoncitos” para aproximarlo. El símil que se me ocurre es lo que hacemos en los juegos de conducción: aunque los controles “izquierda” o “derecha” sean botones y un volante real sea “analógico”, puedo dar toques, más o menos largos, a los controles de izquierda o derecha para aproximar la posición del volante a la deseada. Fijaos en el siguiente gráfico de una demo del Spectrum (“Garfield”):

PWMAudio

Audio PWM en estado puro!! El programa cuenta ciclos para subir o bajar la señal de audio y controlar el ancho de los pulsos… Luego nuestro cerebro hace el resto – y creedme que el resultado es sorprendente!!

Generación de audio

Ya conocemos un poco la teoría, vamos ahora a la práctica. En el ZX Spectrum, para generar audio se usan los bits 4 y 3 de la ULA, escribiendo (por ejemplo) en la posición 0xFE de I/O. Cuando se manipula el bit 4 el nivel de la señal es muy amplio (más “alta”) mientras que el bit 3 se usa para modular la señal que se dirige a la cinta de cassette. Parece simple entonces que, cada vez que nos llegue una escritura a la ULA podamos calcular el nivel de la señal. Pero no nos basta con una muestra: necesitamos generar una señal en el tiempo, o sea que necesitamos muchas muestras. Pero cuantas?

Recordemos que la emulación deja correr al procesador durante un cuadro de pantalla (1/50 de segundo, o lo que es lo mismo 20 milisegundos) para luego hacer una pausa y “sincronizar” la velocidad del Spectrum emulado en nuestro rápido PC. Podemos trabajar en bloques de audio de 1/50 de segundo aprovechando el contador que usamos en la ULA para saber nuestra posición en pantalla.

Ahora sabemos que necesitamos 20 milisegundos de audio por cada cuadro de pantalla. Cuantas muestras son esas? un par de cálculos nos lo dirán: vamos a generar audio a 48KHz, esto es, 48000 muestras por segundo. Eso son (48000/50) = 960 muestras cada 20 milisegundos. Pero qué valor le damos a esas muestras?

No podemos predecir cuándo van a escribir los programas en los controles de audio del chip ULA, pero sí sabemos la posición (en ciclos) con respecto a la pantalla. Aprovechando la función AddCycles() de la ULA, podemos actualizar la muestra que corresponda con la posición del tubo CRT:

#define   AUDIO_SAMPLE_RATE         (48000)
#define   SAMPLES_PER_FRAME         (AUDIO_SAMPLE_RATE / 50)

class ULA : public ULAMemory, ULAIO
{
  unsigned char ULAIOData;
  short FrameAudio[SAMPLES_PER_FRAME];

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

    // Update the analog audio output from ULA
    // First, compute audio output value for this cycle
    int signal = 0;
    signal = (ULAIOData & 0x10) ? +16384 : -16384;
    signal += (ULAIOData & 0x08) ? +8192 : -8192;

    // Now, add audio output over an 8 tap filter:
    // 1: Maintain 7/8ths of the original signal
    audioOutput -= (audioOutput / 8);
    // 2: ...and add 1/8th of the new one
    audioOutput += (signal / 8);

    // Update the audio sample corresponding to this screen tState
    unsigned int offset = (dwFrameTStates * SAMPLES_PER_FRAME) / (TSTATES_PER_SCANLINE * TVSCANLINES);
    // As clocks don't match and this is a quick approximation, limit offset output
    if (offset < SAMPLES_PER_FRAME)
      FrameAudio[offset] = audioOutput;

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

  void IOWrite(unsigned int address,unsigned char value)
  {
    ...
    // save a copy of ULA's state
    ULAIOData = value;
    ...
  }

La función AddCycles() se invoca después de cada instrucción ejecutada por el procesador, y recorrerá de forma más o menos uniforme los casi 69000 ciclos que forman la pantalla. Dado que 69000 ciclos entre 960 muestras dan para muchas repeticiones en cada muestra de audio, la función integra repetidamente la señal con un filtro de 8 taps, esto es, sólo 1/8 de la señal de agrega en cada interacción, algo así como un “condensador en software”. De esta forma, los rápidos cambios en los controles de audio de la ULA se convierten en señales un poco más “analógicas” que, posteriormente, la tarjeta de sonido terminará de digerir. Para la amplitud de la señal he usado +/-16384 para la señal del altavoz y +/- 8192 para la del cassette. Arbitrario? Totalmente.

Ahora que ya tenemos la muestra de 20 milisegundos de audio, es necesario enviarla a la tarjeta de sonido para que la reproduzca, y es aquí donde aparece el mayor problema (con diferencia) para la emulación: cómo sincronizar a la perfección el Spectrum con el audio para que no dé tirones ni cortes.

Para mi la solución consiste en emplear el cronómetro más exacto que se pueda conseguir en un PC.

El temporizador más preciso de un PC: la tarjeta de sonido

Hasta ahora, la solución para “ralentizar” el Spectrum a su velocidad real era ver el tiempo que habíamos necesitados para correr durante 20 milisegundos y complementarlo con un Sleep() del sistema operativo. Aunque funciona (y es válido si no hay sonido), hay que tener en cuenta que la mayoría de los sistemas operativos no son realmente “de tiempo real”: una instrucción Sleep(17) no dura realmente 17 milisegundos, sino cualquier cosa entre 1 (o 0!!) y 30 (o más), dependiendo de la resolución de los “timers” del hardware, de lo ocupada que está la cpu, etc. Cómo conseguimos sincronizar entonces nuestro audio?

Aunque parezca mentira, el mejor cronómetro de un PC moderno es su tarjeta de sonido. En la tarjeta de sonido, 20 milisegundos son realmente 20 milisegundos. De hecho, los reproductores multimedia sincronizan los streams de audio y vídeo empleando el audio como reloj maestro. Nosotros vamos a hacer lo mismo.

Dado que generamos bloques de audio de 20 milisegundos, en lugar de intentar cronometrar nosotros mismos el retardo vamos a emplear el evento de la tarjeta de sonido, enviándole bloques de 20 milisegundos y “congelando” la emulación hasta que nos avise que ya se han terminado de reproducir. Obviamente, tenemos el problema de que cuando “termine de reproducirlos” vamos a tardar un poco en generar otros 20 milisegundos de audio, así que para solucionarlo vamos a utilizar tres bufferes de 20 milisegundos que enviaremos al sistema de audio de golpe. A medida que se vayan consumiendo, tendremos 40 milisegundos de audio todavía pendiente de reproducir para seguir emulando instrucciones y alimentando al sistema de sonido. El resultado? Audio de bajísima latencia sin cortes ni chasquidos.

Cualquiera que haya programado el sistema de audio de Windows sabe las complicaciones que conlleva la reserva de los bloques de memoria, las cabeceras, abrir y cerrar los dispositivos de audio… Vamos a hacerlo encapsulando toda la funcionalidad en una sola clase (bueno, en un par) que nos evite perder el enfoque en nuestro principal problema, que es la emulación. Aquí va el código completo del sistema de audio:

#pragma comment(lib,"WinMM.lib")

template<unsigned int sampleRate> struct AudioBlock
{
  WAVEHDR Header;
  short AudioData[sampleRate / 50];
};

#define   AUDIOBLOCK_COUNT      (3)

template<unsigned int sampleRate> class WaveOutAudioManager
{
public:
  WaveOutAudioManager()
  {
    hWaveDev = NULL;
    hWaveEvent = INVALID_HANDLE_VALUE;
  }
  virtual ~WaveOutAudioManager()
  {
    Close();
  }

public:
  int Open()
  {
    if (hWaveDev != NULL)
      return(ERROR_INVALID_STATE);

    if (hWaveEvent != INVALID_HANDLE_VALUE)
      CloseHandle(hWaveEvent);
    hWaveEvent = CreateEvent(NULL,FALSE,FALSE,NULL);

    WAVEFORMATEX wf;
    ZeroMemory(&wf,sizeof(wf));
    wf.cbSize = sizeof(wf);
    wf.wFormatTag = WAVE_FORMAT_PCM;
    wf.nChannels = 1;   // mono audio
    wf.nSamplesPerSec = sampleRate;
    wf.nAvgBytesPerSec = sampleRate * sizeof(short);
    wf.nBlockAlign = sizeof(short);
    wf.wBitsPerSample = 8 * sizeof(short);
    int result = waveOutOpen(&hWaveDev,WAVE_MAPPER,&wf,(DWORD_PTR)hWaveEvent,NULL,CALLBACK_EVENT);
    if (result != 0)
    {
      Close();
      return(result);
    }

    // Prepare audio buffers. Each one will be 20ms long
    for (int dd=0;dd<AUDIOBLOCK_COUNT;dd++)
    {
      ZeroMemory(&audioBlocks[dd],sizeof(AudioBlock<sampleRate>));
      audioBlocks[dd].Header.lpData = (LPSTR)&audioBlocks[dd].AudioData[0];
      audioBlocks[dd].Header.dwBufferLength = sizeof(audioBlocks[dd].AudioData);
      result = waveOutPrepareHeader(hWaveDev,&audioBlocks[dd].Header,sizeof(audioBlocks[dd].Header));
      if (result != MMSYSERR_NOERROR)
      {
        Close();
        return(result);
      }
    }

    // Everything ready...
    return(0);
  }

  void Close()
  {
    if (hWaveDev != NULL)
    {
      waveOutReset(hWaveDev);
      waveOutClose(hWaveDev);
      hWaveDev = NULL;
    }

    if (hWaveEvent != INVALID_HANDLE_VALUE)
    {
      CloseHandle(hWaveEvent);
      hWaveEvent = INVALID_HANDLE_VALUE;
    }
  }

protected:
  AudioBlock<sampleRate> *FindAvailableBlock()
  {
    for (int dd=0;dd<AUDIOBLOCK_COUNT;dd++)
    {
      if ((audioBlocks[dd].Header.dwFlags & WHDR_INQUEUE) == 0)
        return(&audioBlocks[dd]);
    }
    return(NULL);
  }

public:
  bool QueueAudio(short* audioData)
  {
    // Check if any block is available right now
    AudioBlock<sampleRate> *block = FindAvailableBlock();
    // If no audio block is available, just wait for one (max 20ms)
    if (block == NULL)
    {
      WaitForSingleObject(hWaveEvent,1000);   // Wait for 1 second max
      block = FindAvailableBlock();
    }

    // This is weird... something really strange happened.
    if (block == NULL)
      return(false);

    // Fill block with audio data
    for (int dd=0;dd<sampleRate/50;dd++)
      block->AudioData[dd] = audioData[dd];
    // and send again to audio subsystem
    int result = waveOutWrite(hWaveDev,&block->Header,sizeof(block->Header));
    return(result == MMSYSERR_NOERROR ? true : false);
  }

protected:
  HANDLE hWaveEvent;
  HWAVEOUT hWaveDev;
  AudioBlock<sampleRate> audioBlocks[AUDIOBLOCK_COUNT];
};

La clase WaveOutAudioManager<> inicializa el sistema de sonido y tres bufferes (de 20 milisegundos de audio). La función que nos interesa es QueueAudio(), que recibe 20 milisegundos de audio desde el bucle principal de la emulación. Si encuentra bufferes disponibles (que no estén en ejecución) la función envía el bloque al sistema de audio para su reproducción y vuelve de inmediato. Si todos los bufferes están encolados, la función espera pacientemente a que el sistema de audio nos notifique por medio de un evento que uno de los bloques se ha completado, encolando un nuevo bloque.

Para los bufferes y cabeceras empleo una clase template (AudioBlock<>)que agrega las dos estructuras como una sola, simplificando enormemente el código y haciendo innecesario el uso de reserva dinámica de memoria.

Volviendo ahora a la función principal de emulación (emulatorMain), el código queda así:

  void Spectrum::emulatorMain()
  {
    ...
    // Native audio driver
    WaveOutAudioManager<AUDIO_SAMPLE_RATE> audMgr;

    ...

    audMgr.Open();

    // Ready to roll!!
    do
    {
      // Tape load trap
      if ((cpu.regs.PC == 0x056B) && (OnLoad))
      {
         .... 
      }

      // And 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;
        }

        // Submit audio to driver. The "driver" will hold the loop until an audio buffer
        // is available - that is, 20 ms. This will match the speed of our emulated
        // Spectrum to the real one.
        audMgr.QueueAudio(ula.FrameAudio);
      }
    } while(quitEmulation == false);

    pCurrentUla = nullptr;
    pCurrentKempston = nullptr;
  }

El resultado? Probad simplemente a arrancar el emulador, escribir un programa de un par de líneas y salvarlo con SAVE “test”… veréis las familiares líneas en el borde de la pantalla, junto con el igualmente familiar sonido de la grabación, ese sonido que nos acompañó durante largas tardes de juego.

SpectrumSaving

Pero si queréis ver realmente de lo que era capaz el Spectrum, os recomiendo como un ejemplo absolutamente encantador la música de Alter Ego y de lo que se puede hacer un audio “de un solo bit”.

AlterEgo

Pues creo que eso es todo…

Nuestro emulador está completo, y tenemos un flamante ZX Spectrum de 48KB, capaz de generar sonido (y recuerdos) y un estupendo joystick que podéis adaptar a todas vuestras necesidades. En el último capítulo de este tutorial daremos los últimos toques a la emulación… y podréis descargar el código fuente completo de la misma.