YASS-Yet Another Spectrum Simulator

Hazte un Spectrum - Capítulo 6

Publicado originalmente: 5 de Septiembre de 2012

Queda poco para completar un emulador de Sinclair ZX Spectrum, y hoy nos vamos a dedicar a emular una cinta de cassette para poder volver a disfrutar de esos videojuegos de los ‘80.

A ver, pregunta: cuántos de vosotros no recordabais o incluso sabíais que se usaban cintas de cassette como medio de almacenamiento masivo en los ordenadores personales de los ‘80? Yo me he dado cuenta de lo raro que suena hoy cuando al escribir “cassette” el corrector ortográfico se ha quejado porque no reconoce la palabra… Hoy vamos a darle a nuestro emulador soporte para leer el formato más habitual: los archivos .TAP.

Nuevamente, y gracias a la información encontrada en WorldOfSpectrum.org he obtenido todo lo necesario para implementarlo. Lo primero, un poco de teoría…

Usar cintas de cassette como almacenamiento

Cualquier ordenador que use cintas de audio para almacenar o recuperar programas lo que realmente hace es generar sonidos con ciertos patrones que luego puede reconocer al reproducir la señal. En el caso del ZX Spectrum éste formato es totalmente conocido y se genera (cómo no) con el chip ULA. El chip ULA puede generar sonidos por dos vías diferentes, pero con exactamente las mismas herramientas: un sólo bit (0 ó 1) se emplea para generar una señal de alto nivel (1) o bajo nivel (0) en una salida de audio. Hay dos bits en el registro de I/O de la ULA, uno genera una señal más amplia que la otra. La primera se emplea para activar el altavoz interno del ordenador, mientras que la otra se envía al conector MIC que se enchufa a la cinta de cassette. Realmente las dos señales están unidas, siendo la única diferencia que la salida de la unidad de cassette pasa por una resistencia para atenuar la señal.

Para grabar datos en la cinta basta con modificar la señal de audio a 1 y a 0 contando ciclos entre cada “flanco”. Por ejemplo, el tono de aviso previo a los datos de una cabecera consiste en 8063 pulsos (de 0 a 1 y vuelta a 0) con una duración de 2168 ciclos (tStates) entre flancos. Los bits se codifican igualmente con pulsos: un bit a “0” se codifica como dos pulsos de 855 ciclos entre flancos y un bit a “1” se codifica como dos pulsos de 1710 ciclos entre flancos. La recuperación es igual de simple: la señal de audio entra al chip ULA, que pone un bit a 0 o a 1 dependiendo del nivel de la señal. Los programas en la ROM del Spectrum simplemente leen el bit y calculan el tiempo que la señal está en “alto” o en “bajo”.

El formato .TAP reproduce exactamente el formato binario de los bloques que genera la ROM del Spectrum, sin las señales de sincronismo. Simplemente añade dos bytes antes de cada uno de ellos para poder recuperarlos de un archivo secuencial.

Las rutinas en ROM del Spectrum graban la información como dos bloques separados. El primer bloque contiene una cabecera y mide 17 bytes. El bloque que sigue contiene la información propiamente dicha. Aprovechando el ejemplo más documentado del mundillo (grabar en cinta los dos primeros bytes de la ROM de un Spectrum con SAVE “ROM” CODE 0,2), un ZX Spectrum grabaría los siguiente:

- 8063 pulsos de 2168 ciclos (tStates) cada uno para la señal de aviso de cabecera.
- Un pulso de sincronización de 667 ciclos.
- Un segundo pulso de sincronización de 735 ciclos.
- Un bloque de datos de 19 bytes con la cabecera: un byte con flags, más los datos de la cabecera que incluye tipo de bloque, longitud, ubicación en memoria, etc, y finalmente un byte de suma de control.

Tras una breve pausa, se grabarían los datos propiamente dichos:

- 3223 pulsos de 2168 ciclos para la señal de aviso de cabecera.
- Un pulso de sincronización de 667 ciclos.
- Un segundo pulso de sincronización de 735 ciclos.
- Un bloque de datos con un byte con flags, los dos bytes de datos y un byte con la suma de control del bloque.

Un fichero .TAP equivalente contendría lo siguiente:

- Dos bytes (0x13+0x00 == 0013h ó 19 decimal) con la longitud del bloque
- Los 19 bytes grabados físicamente en la cinta (flags+datos+suma de control)
- Dos bytes (0x03+0x00 == 0004h) con la longitud del bloque.
- Los 4 bytes grabados físicamente en la cinta (flags+datos+suma de control)

Parece obvio que los archivos .TAP contienen los datos grabados por la ROM del Spectrum “tal cual”… no sería posible leerlos de la misma forma?

Interceptando la ROM

Pues realmente sí. Tras leer bastante al respecto y gracias a la inestimable (e imprescindible) ayuda del libro “The Complete ZX Spectrum ROM Dissasembly” que encontraréis (cómo no) en WorldOfSpectrum.org/documentation.html, vamos a interceptar el curso normal de ejecución del código de la ROM y esperar tranquilamente hasta que ésta llegue al punto donde se leen bloques de cinta.

No hay que buscar mucho (nuevamente gracias a la ingente cantidad de información disponible). La función que queremos interceptar está a partir de la posición 056Bh de la ROM y su misión es leer un bloque (tanto de cabecera como de datos propiamente dicho) desde la cinta. Esta función (documentada como “LD-BREAK” en el desensamblado) es el primer (y frontal!!) paso en la lectura de un bloque.

Como la idea es hacer el emulador programable, en lugar de implementar los archivos .TAP directamente en el control lo vamos a implementar como un delegado normal y corriente de .NET. Cada vez que la ROM necesite un bloque de datos, la función que hayamos asignado a dicho delegado (escrita en C#) recibirá la petición y podrá devolver los contenidos. El cambio en la clase es mínimo:

namespace Emulation
{
  public delegate array<Byte>^ LoaderHandler(int bytesToLoad);

  public ref class Spectrum : System::Windows::Controls::Border, IDisposable
  {
  public:
    LoaderHandler^ OnLoad;

   ....

La firma del delegado es la de una función que recibe un número de bytes a cargar y que devuelve un array de bytes con los datos. Ahora vamos a utilizar al delegado desde la emulación.

Dado que cada bucle que ejecuta el emulador consiste en una instrucción de la cpu Z80, para saber si hemos llegado al punto crítico para interceptar la carga basta con comparar el valor del registro PC de la cpu con la dirección que nos interesa antes de emularla:

    // Ready to roll!!
    do
    {
      // Tape load trap
      if ((cpu.regs.PC == 0x056B) && (OnLoad))
      {
        try
        {
          LoadTrap(cpu);
          // set up registers for success
          cpu.regs.BC = 0xB001;
          cpu.regs.altAF = 0x0145;
          cpu.regs.CF = 1;		// Carry flag set: Success
        }
        catch(Exception^)
        {
          // set up registers for failure
          cpu.regs.CF = 0;		// Carry flag reset: Failure
        }
        cpu.regs.PC = 0x05e2;	// "Return" from the "tape block load" routine
      }

      // And emulate next instruction
      cpu.tStates = 0;
      cpu.EmulateOne();
      ...

Fácil, no? Justo antes de emular cada instrucción comprobamos si el registro PC (“Program Counter”) indica que hemos llegado a la dirección del programa de carga. Si es así y el delegado está inicializado en el emulador, invocamos una pequeña función que hará el trabajo sucio: pedir los datos y cargarlos en la memoria del Spectrum. El uso de C++ gestionado hace fácil hablar los dos “idiomas” (.NET y nativo) al mismo tiempo:

void Spectrum::LoadTrap(Z80& cpu)
{
  // Call the delegate to obtain the next tape block
  array<Byte>^ data = OnLoad(cpu.regs.DE);

  // First byte of data contains value for the A register on return. Last
  // byte is blocks checksum (not using it).
  int nBytes = data->Length-2;
  if (cpu.regs.DE < nBytes)
    nBytes = cpu.regs.DE;

  // We must place data read from tape at IX base address onwards
  // DE is the number of bytes to read, IX increments with each byte read.
  for (int dd=0;dd<nBytes;dd++)
  {
    // Write block using cpu's data bus and cpu's registers...
    cpu.DataBus->Write(cpu.regs.IX++,data[dd+1]);
    cpu.regs.DE--;
  }
}

La implementación es tremendamente simple, pero funciona. Si la función delegado devuelve un array de bytes (lo esperado), simplemente usamos los buses del procesador (y sus registros!!) para ir escribiendo los contenidos en la memoria del Spectrum. Cualquier excepción en la función será interpretada como un error que llegará al Spectrum como un “error de cinta”. Una vez completado el trabajo, volvemos a colocar el registro PC de la cpu a la dirección de programa donde todo debería continuar normalmente de emplear una unidad de cinta “de verdad”.

Ficheros .TAP en C#

La parte pesada de todo ésto (la manipulación de los archivos y sus bloques propiamente dicha) la vamos a implementar en C#, porque precisamente para éstas cosas es el lenguaje ideal. La implementación es como sigue:

  class TapBlock
  {
    public TapBlock(BinaryReader stream)
    {
      // A TAP block consists of a two byte header plus data bytes.
      // Data is raw data as saved by Spectrum - and as expected
      // by ROM routines we are replacing.
      UInt16 size = stream.ReadUInt16();
      data = stream.ReadBytes(size);
    }

    public Byte[] Data
    {
      get
      {
        return (data);
      }
    }

    protected byte[] data = null;
  }

  class TapFile
  {
    public void Open(String fileName)
    {
      System.Collections.Generic.List<TapBlock> blockCol = new System.Collections.Generic.List<TapBlock>();

      try
      {
        using (var file = File.OpenRead(fileName))
        {
          var stream = new BinaryReader(file);
          while (stream.BaseStream.Position < stream.BaseStream.Length)
          {
            var newBlock = new TapBlock(stream);
            blockCol.Add(newBlock);
          }
        }
      }
      catch (Exception) { }

      blocks = blockCol.ToArray();
    }

    public TapBlock[] Blocks
    {
      get
      {
        return (blocks);
      }
    }

    protected TapBlock[] blocks = null;
  }

Realmente no es más que eso: un objeto TapFile abre el archivo en disco y lee todos los bloques, añadiéndolos a una colección de objetos TapBlock que obtenemos de su propiedad Blocks. Cada bloque simplemente contiene los datos en su miembro Data.

Cómo no, lo siguiente es que nuestro emulador pueda leer alguna cinta y empezar a hacer cosas verdaderamente divertidas con nuestro Spectrum. Para ello, vamos a añadir un menú a nuestro emulador con la opción “Load”. En el archivo MainWindow.xaml añadimos lo siguiente a nuestra ventana:

  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition/>
    </Grid.RowDefinitions>
    <Menu IsMainMenu="True" Grid.Row="0">
      <MenuItem Header="File">
        <MenuItem Header="Load tape..." Click="OnSelectTape"/>
      </MenuItem>
    </Menu>
    <zx:Spectrum x:Name="emulator" Grid.Row="1" />
  </Grid>

Hemos añadido un simple menú con la opción de leer cintas. Ahora en el archivo de código MainWindow.xaml.cs añadimos lo siguiente:

  public partial class MainWindow : Window
  {
    TapFile currentTape = null;
    int currentTapeBlock = 0;
    ...

    private void OnSelectTape(object sender, RoutedEventArgs e)
    {
      FileDialog dialog = new OpenFileDialog();
      dialog.AddExtension = true;
      dialog.CheckFileExists = true;
      dialog.CheckPathExists = true;
      dialog.Filter = "Tape files (*.tap)|*.tap";
      dialog.DefaultExt = "tap";
      bool? result = dialog.ShowDialog(this);
      if (result == null)
        return;
      if ((bool)result == false)
        return;

      try
      {
        var tape = new TapFile();
        tape.Open(dialog.FileName);
        currentTape = tape;
        currentTapeBlock = 0;
      }
      catch (Exception)
      {
        MessageBox.Show("Unable to load tape file");
      }
    }

En el delegado OnSelectTape() (invocado al selecciona la opción de menú) creamos una instancia de la clase TapFile que obtiene los contenidos del archivo y la asignamos a una variable que leeremos bloque a bloque a medida que el emulador nos lo vaya pidiendo. Sólo queda activar la intercepción de la carga de bloques:

  public partial class MainWindow : Window
  {
    public MainWindow()
    {
      InitializeComponent();

      // Set up load traps
      emulator.OnLoad = TapeLoad;

      emulator.Start();
    }

    protected Byte[] TapeLoad(int bytesToLoad)
    {
      TapBlock currentBlock = currentTape.Blocks[currentTapeBlock++];
      return (currentBlock.Data);
    }

En el constructor conectamos el delegado del emulador a nuestra implementación (la función TapeLoad()). La función intentará leer los bloque secuencialmente (empleando la variable currentTapeBlock como índice) y devuelve directamente el array de bytes al control.

Lo siguiente es una nueva visita (cómo no!) al inmenso archivo de programas de WorldOfSpectrum y descargar nuestros juegos favoritos. En dos clicks encontramos el archivo TAP de Head Over Heels. Vamos a probar el emulador!!

A jugar!

Una vez arrancado el emulador, abrimos el menú y seleccionamos la opción de cargar un archivo. Simplemente buscamos la ubicación del archivo TAP y lo abrimos.

LoadingFirstTape

El hecho de cargar una cinta en el programa no da ninguna indicación al emulador de que la lea – al igual que en un Spectrum real, tras insertar la cinta de cassette requiere que el usuario inicie la carga de la misma. Tecleamos LOAD “” en el emulador (tecla “J” para “LOAD” y CTRL+P para las comillas) y el emulador se pondrá a cargar la cinta que le hemos preparado:

FirstLoad

Tras pulsar retorno de carro, nuestro emulador pedirá bloque a bloque los contenidos de la cinta… Y el resultado es éste:FirstGame

Jugar con el emulador es divertido, pero he perdido (hace mucho!!) la costumbre de usar el teclado y más todavía empleando las combinaciones de teclas que se usan en el Spectrum. Como remate final a nuestro emulador, en el próximo capítulo vamos a añadir lo que creo que pueden ser los dos únicos componentes que quedan para completarlo: la emulación de un joystick Kempston y el sonido! Y no nos vamos a quedar en emular el “beep” tradicional del Spectrum, sino que vamos a soportar (y emular!) los programas que generaban audio multicanal usando un solo bit: el audio PWM.