Tachibana

Astéroric › Artículos › Portar Asteroids a un Oric-1: lo que se resistió

Portar Asteroids a un Oric-1: lo que se resistió

Cuarenta fases para meter un juego vectorial de 1979 en 20 000 ciclos por fotograma. Lo que cedió, lo que se resistió y lo que el emulador nos ocultaba.

Por bmarty · Astéroric 1.0.0-beta · 14 de septiembre de 2026

Asteroids corre en una recreativa de pantalla vectorial: un haz que traza segmentos, sin píxeles, sin memoria de imagen. El Oric-1 es justo lo contrario: un mapa de bits de 240 × 200 puntos, un 6502 a 1 MHz, 48 KB parcialmente ocupados por la pantalla y un chip de sonido con tres canales de onda cuadrada. El proyecto Astéroric es un clon de estudio: tomar la lógica del arcade tal como está escrita en la ROM rev 4 (mismo procesador, lo cual ayuda) y hacer que quepa en esta máquina. Este artículo cuenta lo que se resistió. Los números de fase remiten al CHANGELOG del repositorio, que es la referencia.

1. El presupuesto que no cuadraba

El planteamiento inicial hacía la aritmética: a 1 MHz y 50 fotogramas por segundo se dispone de 20 000 ciclos por fotograma. Una rutina de Bresenham 6502 correcta pero sin código automodificable cuesta de 15 a 22 ciclos por píxel; con 30 segmentos de 20 píxeles, trazados y luego borrados, el presupuesto se agota antes de calcular nada. El documento listaba tres palancas: aceptar 25 Hz, reducir la complejidad de las formas o invertir a fondo en la rutina de línea.

La medición de la fase 2 zanjó la cuestión: ~97 ciclos por píxel, cinco veces la hipótesis. Los 25 Hz se adoptaron de inmediato, como objetivo nominal y no como degradación; es la cadencia de muchos juegos de Oric. El código automodificable de la fase 2b (operandos dx/dy parcheados como inmediatos, sentido del paso Y parcheado a la entrada) solo aportó 4 ciclos por píxel, lejos de los 50 buscados. El verdadero ahorro llegó más tarde, y de otro lado.

2. Dibujar vectores en un mapa de bits

Todo se traza en XOR: dibujar un segmento por segunda vez lo borra, sin guardar el fondo. Es elegante y tiene un defecto inmediato, descubierto en la fase 9g: un píxel compartido por dos segmentos —la punta de la nave, cada vértice de un polígono cerrado— se somete a XOR dos veces y, por tanto, se borra. Todos los vértices de los asteroides eran invisibles. La primera corrección fue un replot explícito de los vértices compartidos (tres XOR = píxel encendido). La corrección buena llegó en la fase 24: un Bresenham semiabierto que traza ]P0, P1], todos los píxeles salvo el de partida. En un polígono recorrido en ciclo, cada vértice recibe entonces exactamente un XOR. Coste neto nulo: un píxel menos por segmento paga la comprobación de entrada.

La ganancia de rendimiento decisiva no vino de la rutina de línea sino de la idea de no volver a llamarla. Los asteroides nunca giran: sus doce siluetas (cuatro formas × tres tamaños) se rasterizan en tiempo de compilación con un script Python que reproduce el Bresenham de line.s, y se emiten como mapas de bits HIRES predesplazados en sus seis fases intra-byte (3 402 bytes de datos). El renderizado pasa a ser un XOR-blit byte a byte, ~23 ciclos por byte no nulo. Un detalle que cuenta: al ser 240 divisible por 6, la instancia fantasma del wraparound (±240 px) conserva la misma fase: un solo juego de mapas de bits. El diezmado de las formas a 6-7 vértices, impuesto en la fase 18h para sostener la cadencia, pudo eliminarse: las muescas finas de las formas de Atari volvieron, puesto que el coste ya no depende del número de vértices.

Una sorpresa en el perfilador, de paso: sizeof(Asteroid) = 13. Al no ser potencia de dos, cada acceso asteroids[i].campo obligaba a cc65 a llamar a la multiplicación por software mul8x16: el 7,1 % del tiempo de CPU. Iterar por puntero la hizo desaparecer del perfil. En total, las fases 24 a 26 llevaron el tiempo de CPU libre del 17,9 % al 39,8 %.

3. El tiempo: el VSync que no existe

La fase 9 sincronizaba el bucle con la patilla CB1 del VIA, que supuestamente recibía la señal VSync de la ULA. El juego corría perfectamente en Phosphoric. Luego Phosphoric 1.16.11 se alineó con el hardware y el juego se quedó en bucle infinito en la pantalla de título: en un Oric real, CB1 no está cableada al VSync. El emulador pulsaba la patilla por comodidad y enmascaraba el error. El sustituto es el Timer 1 del VIA en marcha libre a 20 ms: portable a hardware real, Phosphoric, Oricutron y MAME, a cambio de un tearing teórico nunca percibido.

Quedaba un error de cadencia más sutil, presente desde la fase 18 bajo el nombre de «17 Hz efectivos en escena cargada». frame_wait() muestreaba el contador a la entrada y esperaba dos ticks: mientras el trabajo cabía en el primer tick, la rejilla del temporizador daba 40 ms por fotograma; en cuanto un fotograma superaba los 20 ms, volvía a esperar dos ticks completos: 60 ms en lugar de 40. La fase 28 instaló un planificador de paso fijo con objetivo persistente (frame_target += 2), recuperación en los fotogramas siguientes y resincronización tras una congelación larga. El 50 Hz adaptativo se estudió y se rechazó: toda la física está en píxeles por fotograma, una quincena de familias de temporizadores cuentan en fotogramas y el OVNI avanza ±1 píxel; pasar a 50 Hz a ratos duplicaría la velocidad del juego salvo que se reescalara todo. Un 25 Hz constante y fiable vale más que un 50 Hz intermitente y arriesgado.

4. El parpadeo

A 25 Hz, un objeto borrado al principio del fotograma y redibujado al final está ausente de la pantalla el ~80 % del tiempo: el ojo ve un ciclo presente/ausente a la peor frecuencia posible. La corrección fue estructural: agrupar erase → tick → draw por entidad y sacar de esa ventana todo lo que no es renderizado (sonido, puntuación, textos). Para los asteroides, la ventana pasó de unos 20 ms a 7 ms.

Los torpedos mostraron una variante más traicionera, revelada por un probador en hardware real («fire beam hardly visible»). El antiguo pipeline los borraba al inicio del bucle y los redibujaba al final: ausentes de la memoria de vídeo durante casi todo el cálculo. En hardware real, la fase entre el barrido del CRT y el Timer 1 deriva, así que aparecían de forma intermitente; en Phosphoric la fase está bloqueada y se obtenían 0 capturas de 20 con un torpedo visible, mientras la RAM decía que estaba activo. El esquema commit (borrar en la posición del último trazado, redibujar justo después, estado de pantalla desacoplado del estado lógico) dio 8 capturas de 8. Los torpedos pasaron además a 2 × 2 px.

5. La ROM que se esquiva

El teclado se lee sin el búfer de la ROM, directamente de la matriz, lo que supone haber entendido la matriz. Primera versión errónea: el código probaba columnas distintas manteniendo una máscara R14 fija. En el hardware, SPACE y las cuatro flechas están todas en la columna 4 y se distinguen por su fila, seleccionada mediante el registro 14 del PSG. Solo el SHIFT derecho producía un falso positivo. Segunda trampa, propia del 6522: leer ORB devuelve lo escrito en los bits configurados como salida, no el estado de las patillas. Si la ROM dejó DDRB = $FF, PB3 relee su propio valor y la lectura se vuelve aleatoria: de ahí disparos fallidos y giros incoherentes. key_scan fuerza ahora PB3 como entrada durante el barrido.

Tercera trampa, oída antes de ser comprendida: para pilotar el puerto A del PSG en modo matriz, key_scan escribía R7 = $7F —«mezclador todo en silencio»— en cada fotograma. Los seis bits de tono y ruido se cortaban una vez por fotograma y seguían cortados hasta el siguiente tick sonoro par: un troceado a 25 Hz de todos los efectos, sobre todo audible en los largos. Era el «error de audio de la explosión de la nave» del backlog. La corrección reescribe el último valor real del mezclador, conservado en un mixer_shadow.

El reproductor de sonido bajo interrupción (fase 20) exigió saber qué hace la ROM antes de saltar al vector de usuario: nada. El 6502 solo apila PC y P; la salvaguarda de A, X e Y ocurre dentro del manejador de la ROM, que se puentea al parchear el vector. Sin PHA/TXA/TYA propios, el triple PLA final desapila P y la dirección de retorno, y el RTI salta a cualquier parte. También hay que desactivar todas las demás fuentes de IRQ del VIA antes de armar T1; si no, un residuo CA1/CB1/T2 de la ROM dispara un manejador que nunca lo reconoce: bucle infinito. Validado en Phosphoric con --trace-irq: 493 entradas, 493 RTI, de 19 930 a 19 970 ciclos entre dos IRQ.

El manejador se engancha a los dos vectores, $0228 (Oric-1) y $0244 (Atmos). No bastó: con la ROM Atmos, el juego no funciona, ni en Phosphoric ni en Oricutron. La compatibilidad Atmos sigue siendo un sprint aparte, y el sitio lo dice.

6. La BSS, dos veces

C supone que una variable estática vale cero al arrancar; es el crt0 quien debe garantizarlo. Fase 9b: con una BSS de más de $83 bytes, la pantalla HIRES ya no se activaba; el zerobss casero difería sutilmente del de cc65, y la solución fue adoptar la rutina oficial mediante initlib. Fase 36, un mes después: aparece un bloque fantasma en la posición (85, 85) con el primer disparo. 85 es $55, el patrón de la RAM sin inicializar. El comentario «initlib llama a zerobss» era falso —cc65 los llama por separado— y la BSS, en realidad, seguía sin limpiarse. Dos revisiones de código no habían releído un comentario.

7. El sonido de otra máquina

La recreativa de 1979 produce sus sonidos con osciladores analógicos discretos; no hay nada que portar. El punto de partida fue otro 8 bits: Mine Storm, el clon de Asteroids de la Vectrex (1982). Procesador distinto (6809), pero el mismo chip AY-3-8912 y el mismo VIA 6522: el código no se transpone, los valores de registro sí. Las tablas de SS.THR, SS.BLT y SS.EXP sirvieron de primera aproximación.

El resto se hizo de oído y con el espectro: análisis FFT de grabaciones de la recreativa. Las tres explosiones (asteroide pequeño, mediano, grande) tienen duraciones similares y solo difieren en la frecuencia del ruido: ~167, 246 y 306 Hz, es decir R6 = 12, 8 y 6. El disparo pasó de un ruido grave a ~740 Hz. La arquitectura siguió a la recreativa: tres circuitos independientes se convierten en tres canales dedicados —A para los efectos, B para el thump-thump que ninguna explosión interrumpe, C para el platillo— con un mezclador recalculado a partir del estado de los tres. La sintonía de título (Grieg, dominio público) fue inaudible durante tres fases: la rutina guardaba el índice de nota en sound_tmp y luego llamaba a psg_write… que usa sound_tmp como borrador. La nota releída era el último valor escrito en el PSG. Bastó un espacio temporal dedicado, colocado en BSS porque la página cero estaba llena hasta el último byte.

8. El emulador que miente, el hardware que responde

Astéroric se desarrolló en Phosphoric, y los dos proyectos se corrigieron mutuamente. El emulador enmascaró el VSync inexistente (§3) y luego un modelo IJK erróneo: la primera lectura del joystick pasaba por el registro 14 del PSG, tal como Phosphoric lo simulaba, y no funcionaba en la interfaz física de un probador. El protocolo real pasa por el puerto de impresora —puerto A del VIA directo, strobe PB4, bit 5 de presencia— y se validó contra Oricutron; Phosphoric se corrigió en paralelo, con una repetición de la secuencia 6502 exacta del juego en sus tests.

El hardware también reveló lo que ningún emulador podía mostrar. El atributo de vídeo $1C escrito en la inicialización selecciona HIRES a 60 Hz; el bit 1 selecciona 50 Hz, y hace falta $1E. El contenido de la memoria de vídeo es idéntico en ambos casos, así que la captura es idéntica, pero el RGB2HDMI del probador no enganchaba la señal. Sin consecuencia para la velocidad del juego, marcada por el Timer 1 y no por el barrido. En sentido inverso, el emulador aportó los instrumentos: capturas sin interfaz comparadas bit a bit (make check), perfil de 25 millones de ciclos, traza de las IRQ, volcados de RAM simbolizados, escenarios guionizados con --type-keys. Con una trampa: una pulsación guionizada dura 40 ms, exactamente un fotograma a 25 Hz, y puede caer sistemáticamente entre dos barridos de teclado en ejecución determinista.

9. Lo que queda

Fuentes y enlaces

← Astéroric · Funciones →