Creación de una máquina arcade emulador. Parte 4

imagen

Partes del primero , segundo , tercero .

El resto de la máquina


El código que escribimos para emular el procesador 8080 es bastante general y puede adaptarse fácilmente para ejecutarse en cualquier máquina con el compilador de C. Pero para poder jugar el juego en sí, necesitamos hacer más. Tendremos que emular el equipo de toda la máquina arcade y escribir código que pegue las características específicas de nuestro entorno informático al emulador.

(Quizás le interese mirar el diagrama de circuito de la máquina).

Tiempos


El juego se ejecuta en el 8080 de 2 MHz. Su computadora es mucho más rápida. Para tener esto en cuenta, tendremos que encontrar algún tipo de mecanismo.

Interrupciones


Las interrupciones están diseñadas para que el procesador pueda procesar tareas con tiempos de ejecución precisos, como E / S. El procesador puede ejecutar el programa, y ​​cuando se activa el pin de interrupción, deja de ejecutar el programa actual y hace algo más.

Necesitamos simular la forma en que una máquina arcade genera interrupciones.

Gráficos


Space Invaders dibuja gráficos en su memoria en el rango de direcciones 0x2400. Un controlador de video de hardware real leería RAM y controlaría una pantalla CRT. Nuestro programa tendrá que emular este comportamiento presentando una imagen del juego en una ventana.

Botones


El juego tiene botones físicos que el programa lee usando el comando IN del procesador 8080. Nuestro emulador necesitará vincular la entrada del teclado a estos comandos IN.

ROM y RAM


Debo admitir: "cortamos la esquina" al crear un búfer de memoria de 16 kilobytes, que incluye los 16 KB inferiores de la asignación de memoria del procesador. De hecho, los primeros 2 KB de asignación de memoria es una memoria real de solo lectura (ROM). Tendremos que poner operaciones de escritura en la memoria en una función para que no sea posible escribir en la ROM.

Sonido


Hasta ahora no hemos dicho nada sobre el sonido. Space Invaders tiene un lindo esquema de sonido analógico que reproduce uno de los 8 sonidos controlados por el comando OUT, que se transmite a uno de los puertos. Tendremos que convertir estos comandos OUT para reproducir muestras de sonido en nuestra plataforma.

Puede parecer mucho trabajo, pero no es tan malo y podemos avanzar gradualmente. Lo primero que queremos hacer es ver la pantalla, para lo cual necesitamos interrupciones, gráficos y parte del procesamiento de los comandos IN y OUT.

Pantallas y actualizaciones


Los fundamentos


Probablemente esté familiarizado con los componentes de un sistema de visualización de video. En algún lugar del sistema hay algún tipo de RAM, que contiene una imagen para mostrar en la pantalla. En el caso de dispositivos analógicos, existe un equipo que lee esta RAM y convierte los bytes en voltaje analógico transmitido al monitor.

Una comprensión más profunda del sistema nos ayudará a la hora de analizar el propósito de la asignación de memoria y la funcionalidad del código.

Las pantallas analógicas tienen requisitos para frecuencias de actualización y tiempos. En cualquier momento, la pantalla tiene un píxel específico actualizado. La imagen transmitida a la pantalla se llena punto por punto, comenzando desde la esquina superior izquierda y hacia la derecha superior, luego el primer punto de la segunda línea, el último punto de la segunda línea, etc. Después de que se dibuja la última línea en la pantalla, el controlador de video puede generar una Interrupción vertical en blanco (también conocida como VBI o VBL).

Para garantizar una animación fluida, la imagen en RAM procesada por el controlador de video no se puede cambiar. Si la actualización de RAM ocurrió en el medio del marco, el espectador verá partes de dos imágenes. Esto da como resultado un efecto de "rasgadura" cuando se muestra un cuadro diferente del cuadro en la parte inferior de la pantalla. Si alguna vez has visto un salto de línea, sabes cómo se ve.

Para evitar vacíos, el software debe hacer algo para evitar transferir la ubicación de la actualización de la pantalla. Y solo hay una forma de hacerlo.

El VBL se genera después del final de la última línea y, por lo general, hay un cierto tiempo antes de volver a dibujar la primera línea. (Este es el tiempo en blanco vertical, y puede ser de aproximadamente 1 milisegundo).

Cuando se recibe VBL, el programa comienza a representar la pantalla desde arriba.

Cada línea se dibuja antes del proceso inverso de escaneo de cuadros.

La CPU siempre está por delante del retorno en caliente y, por lo tanto, evita los saltos de línea.

imagen

Sistema de video Space Invaders


Una página muy informativa nos dice que los Space Invaders tienen dos interrupciones de video. Uno es para el final del cuadro, pero también genera una interrupción en el medio de la pantalla. La página describe el sistema de actualización de pantalla: el juego dibuja gráficos en la mitad superior de la pantalla cuando recibe una interrupción en el medio de la pantalla, y dibuja gráficos en la parte inferior de la pantalla cuando recibe una interrupción al final del cuadro. Esta es una forma bastante inteligente de eliminar los saltos de línea y un buen ejemplo de lo que se puede lograr al desarrollar hardware y software al mismo tiempo.

Debemos forzar la emulación de nuestra máquina para generar tales interrupciones. Si los generaremos con una frecuencia de 60 Hz, así como con la máquina Space Invaders, entonces el juego se dibujará con la frecuencia correcta.

En la siguiente sección, hablaremos sobre la mecánica de las interrupciones y pensaremos en cómo emularlas.

Botones y puertos


El 8080 implementa E / S usando las instrucciones IN y OUT. Tiene 8 puertos IN y OUT separados: el puerto está determinado por el byte de datos del comando. Por ejemplo, IN 3 colocará el valor del puerto 3 en el registro A, y OUT 2 enviará A al puerto 2.

Tomé información sobre el propósito de cada puerto del sitio web de Computer Archaeology . Si esta información no estuviera disponible, tendríamos que obtenerla estudiando el diagrama del circuito, así como leyendo y ejecutando el código paso a paso.

:
1
0 (0, )
1 Start
2 Start
3 ?
4
5
6
7 ?

2
0,1 DIP- (0:3,1:4,2:5,3:6)
2 ""
3 DIP- , 1:1000,0:1500
4
5
6
7 DIP-, 1:,0:

3

2 ( 0,1,2)
3
4
5
6 "" ? , ,
(0=a,1=b,2=c ..)

( 3,5,6 1=$01 2=$00
, (attract mode))


Hay tres formas de implementar E / S en nuestra pila de software (que consta de un emulador 8080, código de máquina y código de plataforma).

  1. Incruste conocimiento de la máquina en nuestro emulador 8080
  2. Incruste el conocimiento del emulador 8080 en el código de máquina
  3. Invente una interfaz formal entre las tres partes del código para permitir el intercambio de información a través de la API

Descarté la primera opción: es bastante obvio que el emulador está en la parte inferior de esta cadena de llamadas y debe permanecer separado. (Imagine que necesita reutilizar el emulador para otro juego, y comprenderá lo que quiero decir). En el caso general, transferir estructuras de datos de alto nivel a niveles inferiores es una solución arquitectónica deficiente.

Elegí la opción 2. Permítanme mostrar el código primero:

  while (!done) { uint8_t opcode = state->memory[state->pc]; if (*opcode == 0xdb) //machine specific handling for IN { uint8_t port = opcode[1]; state->a = MachineIN(state, port); state->pc++; } else if (*opcode == 0xd3) //OUT { uint8_t port = opcode[1]; MachineOUT(state, port); state->pc++; } else Emulate8080Op(state); } 

Este código vuelve a implementar el procesamiento de códigos de operación para IN y OUT en la misma capa, que llama al emulador para el resto de los comandos. En mi opinión, esto hace que el código sea más limpio. Esto es similar a una anulación o subclase para los dos comandos, que se refiere a una capa de autómata.

La desventaja es que transferimos la emulación de códigos de operación en dos lugares. No te culparé por elegir la tercera opción. En la segunda opción, se requiere menos código, pero la opción 3 es más "limpia", pero el precio es un aumento en la complejidad. Esta es una cuestión de elección de estilo.

Registro de turnos


La máquina Space Invaders tiene una solución de hardware interesante que implementa un comando bit shift. El 8080 tiene comandos para un cambio de 1 bit, pero se necesitarán docenas de comandos 8080 para implementar un cambio de varios bits / byte. El hardware especial permite que el juego realice estas operaciones en solo unas pocas instrucciones. Con su ayuda, cada cuadro se dibuja en el campo del juego, es decir, se usa muchas veces por cuadro.

No creo que pueda explicarlo mejor que el excelente análisis de la arqueología informática:

; 16- :
; f 0
; xxxxxxxxyyyyyyyy
;
; 4 x y, x, :
; $0000,
; write $aa -> $aa00,
; write $ff -> $ffaa,
; write $12 -> $12ff, ..
;
; 2 ( 0,1,2) 8- , :
; offset 0:
; rrrrrrrr result=xxxxxxxx
; xxxxxxxxyyyyyyyy
;
; offset 2:
; rrrrrrrr result=xxxxxxyy
; xxxxxxxxyyyyyyyy
;
; offset 7:
; rrrrrrrr result=xyyyyyyy
; xxxxxxxxyyyyyyyy
;
; 3 .


Para el comando OUT, escribir en el puerto 2 establece la cantidad de desplazamiento, y escribir en el puerto 4 establece los datos en los registros de desplazamiento. Leer con IN 3 devuelve datos desplazados por la cantidad de desplazamiento. En mi máquina, esto se implementa así:

  -(uint8_t) MachineIN(uint8_t port) { uint8_t a; switch(port) { case 3: { uint16_t v = (shift1<<8) | shift0; a = ((v >> (8-shift_offset)) & 0xff); } break; } return a; } -(void) MachineOUT(uint8_t port, uint8_t value) { switch(port) { case 2: shift_offset = value & 0x7; break; case 4: shift0 = shift1; shift1 = value; break; } } 

Teclado


Para obtener la respuesta de la máquina, debemos vincular la entrada del teclado. La mayoría de las plataformas tienen una forma de recibir pulsaciones de teclas y eventos de lanzamiento. El código de plataforma para los botones tendrá el siguiente aspecto:

  if(PeekMessage(&msg,NULL,0,0,PM_REMOVE)) { if (msg.message==WM_KEYDOWN ) { if ( msg.wParam == VK_LEFT ) MachineKeyDown(LEFT); } else if (msg.message==WM_KEYUP ) { if ( msg.wParam == VK_LEFT ) MachineKeyUp(LEFT); } } 

El código de máquina que pega el código de la plataforma al código del emulador se verá así:

  MachineKeyDown(char key) { switch(key) { case LEFT: port[1] |= 0x20; //Set bit 5 of port 1 break; case RIGHT: port[1] |= 0x40; //Set bit 6 of port 1 break; /*....*/ } } PlatformKeyUp(char key) { switch(key) { case LEFT: port[1] &= 0xDF //Clear bit 5 of port 1 break; case RIGHT: port[1] &= 0xBF //Clear bit 6 of port 1 break; /*....*/ } } 

Si lo desea, puede combinar el código de la máquina y la plataforma como desee; esta es la opción de implementación. No haré esto porque voy a portar la máquina a varias plataformas diferentes.

Interrupciones


Después de estudiar el manual, me di cuenta de que el 8080 maneja las interrupciones de la siguiente manera:

  1. La fuente de interrupción (externa a la CPU) establece el pin de interrupción de la CPU.
  2. Cuando la CPU confirma que se recibió la interrupción, la fuente de la interrupción puede enviar cualquier código de operación al bus y la CPU lo ve. (La mayoría de las veces usan el comando RST).
  3. La CPU ejecuta este comando. Si es RST, entonces este es un análogo del comando CALL para una dirección fija en la parte inferior de la memoria. Empuja la PC actual en la pila.
  4. El código en la dirección de memoria inferior procesa lo que la interrupción quiere decirle al programa. Una vez que se completa el procesamiento, RST finaliza con una llamada a RET.

El equipo de video del juego genera dos interrupciones que debemos emular mediante programación: el final del cuadro y el medio del cuadro. Ambos se realizan a 60 Hz (60 veces por segundo). 1/60 de segundo es 16.6667 milisegundos.

Para simplificar el trabajo con interrupciones, agregaré una función al emulador 8080:

  void GenerateInterrupt(State8080* state, int interrupt_num) { //perform "PUSH PC" Push(state, (state->pc & 0xFF00) >> 8, (state->pc & 0xff)); //Set the PC to the low memory vector. //This is identical to an "RST interrupt_num" instruction. state->pc = 8 * interrupt_num; } 

El código de la plataforma debe implementar un temporizador al que podamos llamar (por ahora, solo lo llamo hora ()). El código de máquina lo usará para pasar una interrupción al emulador 8080. En el código de la máquina, cuando expire el temporizador, llamaré a GenerateInterrupt:

  while (!done) { Emulate8080Op(state); if ( time() - lastInterrupt > 1.0/60.0) //1/60 second has elapsed { //only do an interrupt if they are enabled if (state->int_enable) { GenerateInterrupt(state, 2); //interrupt 2 //Save the time we did this lastInterrupt = time(); } } } 

Hay algunos detalles de cómo el 8080 maneja realmente las interrupciones, que no emularemos. Creo que dicho procesamiento será suficiente para nuestros propósitos.

Source: https://habr.com/ru/post/es419143/


All Articles