Juego de la Vida en ensamblador para MSX

elpamplinadecai@gmail.com

@ElPamplina@masto.es

Publicado el 18-5-2024

Introducción histórica

Esta historia comienza cuando yo tenía 16 años, es decir, en un universo muy muy lejano. Yo era orgulloso poseedor de un Sony Hitbit MSX, lo cual era lo más parecido a ser un outsider de la informática, en un mundillo dominado por los Spectrum y los Amstrad. Los del MSX éramos los raritos dentro de los raros (el término friki todavía no se usaba entonces).

El Sony HB-75P

Pantalla de arranque del MSX

Pantalla de bienvenida del Sony HitBit con el programa organizador personal que incorporaba.

El MSX era fruto de un consorcio de fabricantes (parecido a lo que fue el VHS), entre los que estaban Sony, Phillips y muchos más. Tuvo poca penetración en Europa, y bastante más en Japón. Para esa primera versión del sistema apostaron por usar el mismo procesador de los Spectrum (el Zilog Z80), quizá con la esperanza de atraer a programadores de la competencia.

El software se lo encargaron a una joven empresa poco conocida, Microsoft, que por entonces estaba haciendo sistemas para IBM y podía aprovechar buena parte del desarrollo del lenguaje BASIC que estaba haciendo para el PC.

El desarrollo del MSX BASIC se realizó a partir del GW-BASIC que se solía usar en los primeros PC-DOS y MS-DOS, y la BIOS era una simplificación del sistema de los PC, que se podía ampliar con un subsistema extra denominado MSX-DOS para los equipos con unidades de disco. Por tanto, nuestros queridos MSX eran casi lo más parecido a un PC que se podía encontrar en el mercado doméstico.

Mi camino hacia el código máquina

Al contrario que muchos, yo no había llegado al mundo de los ordenadores para jugar a marcianitos, aunque también, sino porque ya entonces me apasionaba la programación. Un bicho como mi MSX era el campo perfecto de experimentación. En un par de años ya me había hecho programas para casi todo lo que me había pasado por la cabeza, incluso había ganado un premio en una feria de ciencias de mi instituto con un juego sobre en aparato digestivo.

Con las 3000 pesetas del premio, un pastón para mi inexistente economía, me compré un libro especializado en ensamblador para el MSX. Eso del ensamblador era llegar al Shangri-La de la programación, el Sancta Sanctorum. Hasta ahora había programado usando el BASIC que traía instalado el MSX, y el ensamblador era la oportunidad de saltarme las limitaciones de rendimiento y acceso a memoria para sacarle todo el jugo a la máquina que tenía delante.

Portada del libro. El mío debe estar en algún sitio en casa de mis padres, y seguro que no estaba tan estropeado.

El libro traía un programa ensamblador escrito en BASIC, para traducir las instrucciones a código máquina, el cual tecleé enterito solo para darme cuenta de que ¡no funcionaba! Intenté usar todos mis (muy limitados) conocimientos en programación para arreglarlo, pero no pude. Intenté escribir yo mismo un ensamblador, que quedó bastante bien, pero adolecía de las mismas lagunas que mi conocimiento técnico. No olvidemos que no había Internet ni nada parecido, y todo lo que yo sabía era por un libro y algunos artículos en revistas.

Sin eso, todo lo que aprendiera de código máquina no me servía para nada, así que me decidí a comprar el ensamblador que anunciaban en la revista MSX-Club, que estaba editado por la propia editorial de la revista (Manhattan Transfer S.A., un sugerente nombre).

Portada del número 28 de MSX-Club

La editorial tenía la curiosa política de no admitir contrarreembolsos, sino que había que enviar el importe en un cheque bancario. Ni corto ni perezoso, me fui a la Caja Postal y abrí mi primera cuenta corriente, solo para poder rellenar un cheque y pedir el programa. Ya veis lo picado que estaba.

Recibí la casete con el programa ensamblador, el cual resultó ser una maravillosa creación de software. Usaba el mismo entorno de programación del BASIC, pero con el lenguaje ensamblador en su lugar, lo cual era fantástico para quien, como yo, estaba acostumbrado a ese entorno.

Pantalla inicial del ensamblador RSC versión 1

Al creador, Ramón Sala, nunca lo he conocido, pero desde aquí le mando mi abrazo y mi admiración. Una obra maestra.

El juego de la vida y los problemas de memoria

Por entonces había leído en algún sitio, no recuerdo dónde, acerca del Juego de la Vida de Conway, y me pareció un reto perfecto para programarlo en mi MSX.

Juego de la vida - Wikipedia

Rápidamente hice una primera versión en BASIC, pero que era inutilizable por su lentitud y también por la poquísima memoria que tenía disponible en dicho entorno.

El modelo de Hitbit que tenía contaba con sus orgullosos ¡80 KB! de memoria RAM, lo cual era mucho para la época, pero poco usable. De entrada, el venerable procesador Z80 manejaba direcciones de 16 bits, lo que significa que solo podía direccionar 64 KB de memoria. Para usar los 16 KB fuera del espacio de direcciones había que usar técnicas de manejo de entrada/salida (desconocidas para mí) solo al alcance del lenguaje ensamblador.

Por otra parte, la memoria ROM que albergaba el sistema y el intérprete de BASIC ocupaba sitio en ese espacio de direcciones, por lo que la RAM accesible en la práctica eran mucho menos de los 64 KB iniciales. El sistema se encargaba de recordártelo en el prompt inicial nada más arrancar: 28815 bytes free.

Pantalla inicial del MSX BASIC

Para colmo, la forma en que el BASIC maneja los datos no es la más eficiente posible en cuestión de memoria. El MSX BASIC solo tenía datos numéricos en coma flotante, nada de enteros ni mucho menos bits individuales. Eso significaba que si mi juego de la vida tenía que manejar una matriz, digamos, de 32x32, estaba ocupando en memoria el espacio para 1024 números en coma flotante, lo cual se traduciría seguramente en más de 4 KB de mi preciosa memoria. Si en su lugar hubiera usado matrices de cadenas (cosa que en ese momento no se me ocurrió), hubiera ido la cosa un poco mejor, aunque igualmente hacer un juego de la vida de diez o veinte generaciones hubiera sido inabarcable.

Una celda del juego de la vida en realidad ocupa muchísimo menos que un número en coma flotante, de hecho se puede representar con un solo bit (1=la celda está ocupada, 0=la celda está vacía). Así que me propuse hacer un juego de la vida en ensamblador donde manejara los bits individualmente. Eso estaba completamente fuera de mis conocimientos de programación y me estrellé estrepitosamente.

Como dije antes, sabiendo lo que ahora sé, me doy cuenta de que se podría haber aprovechado mejor la memoria haciendo un array de cadenas de caracteres, con un carácter (por ejemplo el asterisco) indicando una celda ocupada, y un espacio indicando la celda vacía. Esto hubiera ocupado más espacio que la codificación bit a bit, pero muchísimo menos que la representación numérica (concretamente, un byte por celda) y mucho más fácil de programar.

Con esta representación, los 15080 bytes libres del sistema me hubieran dado para almacenar hasta 14 generaciones de 32x32, lo cual hubiera sido suficiente para cantar victoria.

Al poco tiempo de aquella fiebre ensambladora, entré en la universidad a la carrera de informática, donde se programaba en Pascal y C, así que el MSX se quedó solo como consola para jugar los videojuegos que me pasaba algún amigo en una casete regrabada mil veces. El reto del juego de la vida en ensamblador se quedó inconcluso... HASTA HOY.

Cuarenta años después, me he encontrado por casualidad con un maravilloso emulador denominado openMSX y ahora puedo recrear mi viejo Hitbit en mi portátil. También he conseguido el viejo ensamblador de Ramón Sala (una versión mucho mejor llamada RSC II) en un repositorio de software retro, y me he decidido a terminar lo que empecé hace ocho lustros. ¡¡El JUEGO DE LA VIDA para MSX renacerá de sus cenizas!!

Web oficial de OpenMSX

Midiendo la memoria

En aquellos tiempos no tenía nada claro qué espacio de memoria podía usar para almacenar las celdas de mi juego de la vida. Es algo que ninguno de los libros que pude consultar aclaraba. El libro de Data Becker empezaba sus programas en la dirección &HF000 (&H es el prefijo para los números hexadecimales en el MSX). Eso dejaba menos de 4 KB teóricos para almacenar, pero no explicaba cómo aprovechar mejor esos supuestos 15 KB que me decían que tenía.

Me faltaba un buen mapa de memoria, como el que hoy se puede encontrar en:

The Memory - MSX Wiki

Resumiendo, la dirección más baja de RAM libre (que depende de la cantidad de RAM del equipo), se define en la variable BOTTOM (dirección &HFC48). Estas variables del sistema son de 16 bits, y el Z80 es "little endian", por lo que los 8 bits menos significativos están en la dirección &HFC48 y los 8 más significativos en &HFC49.

En ordenadores con 32 KB o más de RAM, BOTTOM vale &H8000, señalando así la dirección más baja accesible de la RAM. Pero no podemos empezar a usar directamente esa memoria, porque hay que dejar sitio a la pila del sistema (donde se guardan, entre otras cosas, las direcciones de retorno a subrutinas). La cabeza de la pila (dirección más alta ocupada por esta) se guarda en la variable DSKTOP (&HF674).

Después de la cabeza de la pila, hay que dejar además espacio para la zona de almacenamiento de cadenas de caracteres (que son 200 bytes por defecto).

El final del espacio de cadenas (variable MEMSIZ, &HF672), va seguido de un espacio adicional reservado para el sistema y ¡por fin! la variable HIMEM (&HFC4A) delimita el inicio de la "tierra de nadie" que se puede usar para almacenar nuestros programas y datos en código máquina.

La dirección más alta que se puede usar es fija en todos los MSX (&HF37F). Desde &HF380 hasta &HFFFF hay un espacio de trabajo del sistema que por nuestro bien, en teoría, no debemos tocar.

Para establecer el valor de todas esas variables, usamos el comando CLEAR. Su primer argumento es el tamaño a reservar para cadenas de caracteres, y el segundo es el valor para HIMEM, primera dirección que queremos libre para poder usar. A partir de esos parámetros, el resto de áreas se establecen automáticamente. Cuanto más baja sea la dirección HIMEM usada, más baja estará la pila y más espacio nos quedará libre hasta el límite fijo en &HF380.

Haciendo pruebas con el emulador, he comprobado que el HIMEM más bajo que me hubiera permitido mi querido Hitbit es &H8600. El siguiente programa establece ese límite y muestra por pantalla como quedan el resto de variables:

5 CLEAR 200, &H8600
10 PRINT "BOTTOM (Inicio memoria libre): &H"; HEX$(PEEK(&HFC49)*256 + PEEK(&HFC48))
20 PRINT "DSKTOP (Base de la pila, inicio área de cadenas): &H"; HEX$(PEEK(&HF675)*256 + PEEK(&HF674))
30 PRINT "MEMSIZ (Fin área de cadenas): &H"; HEX$(PEEK(&HF673)*256 + PEEK(&HF672))
40 PRINT "HIMEM (Inicio área de usuario): &H"; HEX$(PEEK(&HFC4B)*256 + PEEK(&HFC4A))
50 PRINT "(fijo) Inicio área del sistema, fin área de usuario: &HF380"

El resultado es:

BOTTOM (Inicio memoria libre): &H8000
DSKTOP (Base de la pila, inicio área de cadenas): &H8320
MEMSIZ (Fin área de cadenas): &H83E8
HIMEM (Inicio área de usuario): &H8600
(fijo) Inicio área del sistema, fin área de usuario: &HF380"

Esto significa que estaría poniendo la pila en &8320-&H8000, dejándole unos exiguos 800 bytes. Mi campo de juego estaría en &H8600-&HF37F (28032 bytes). Por supuesto, esto es seguramente poco recomendable y espero no necesitar tanto para nuestro juego.

La pantalla del MSX tiene 40x24 caracteres en el modo 0. Por lo tanto, me planteo un tablero máximo de 24x24 y usar las 16 columnas de la derecha para mostrar información de progreso.

No es buena idea representar las celdas del juego con bits individuales, lo que mi mente calenturienta de adolescente pretendía, porque la cosa se complicaría demasiado, y además no es necesario. Voy a usar un byte completo para representar cada celda. Esto da 24x24 = 576 bytes por generación. Evitando ocupar el máximo de 28 KB, podemos plantear usar unos 20 KB, que daría un límite de 34 generaciones, que está muy bien. Esto fijaría el HIMEM de nuestro programa a partir de &HA500.

Planteamiento del problema

Lo mínimo que necesita el programa en cada ciclo es tener en memoria una generación (ya completa) y espacio para la siguiente (el área de trabajo), para ir construyendo una a partir de la otra. Para simplificar, voy a ir guardando una generación tras otra, hasta que se agote la memoria (el programa se colgará si nos pasamos). En futuras versiones más refinadas se podrá hacer cíclico, e incluso que se pueda ir hacia atrás en las últimas generaciones.

De cada byte que representa una celda, me bastaría con controlar el estado de un único bit, pero para facilitar el uso del caracteres que se puedan representar y queden bien, utilizaré el asterisco (código ASCII 2A, 00101010 en binario) y el espacio (código hexadecimal 20, 00100000 en binario). Bastará con comprobar el bit 1 (segundo por la derecha) para saber si una celda está ocupada.

La rutina principal va a ser pasar por cada una de las celdas, usando un puntero que se irá incrementando y aplicarle un algoritmo que contará cuántas celdas adyacentes están vivas (esta es la parte complicada). Una vez calculado, se actuará en consecuencia, escribiendo en el área de trabajo la celda correspondiente (20=muerta, 2A=viva).

Las tres reglas de Conway las aplicaré de esta manera:

- Si tiene exactamente 3 celdas adyacentes vivas, la celda estará viva en la siguiente generación, sin importar su estado actual.

- Si tiene 2 celdas adyacentes vivas y su estado actual es viva, entonces seguirá viva en la siguiente generación.

- En cualquier otro caso, la celda estará vacía en la siguiente generación.

Esqueleto de programa

Voy a empezar por el esqueleto, es decir, la parte que prepara la pantalla y muestra la generación cero. Después de eso, esperará a que se pulse una tecla y empezará a sacar generaciones. En cada pulsación de tecla se adelanta una generación, mientras que pulsar ESC nos sacará del programa.

La generación cero será en principio fija, definida con directivas DEFM en el código ensamblador. Más tarde haremos el programita BASIC para cargar cualquier tablero que se nos ocurra en las mismas direcciones.

Manejo de pantalla

No voy a meterme en definir gráficos, sino que iré a lo práctico. En el modo de pantalla 0, el más simple, el mapa de caracteres (esquina superior izquierda) empieza en la dirección 0 de la memoria VRAM, ocupando 40 posiciones cada fila.

Bastará con ir moviendo los datos desde la RAM a las posiciones adecuadas de ese mapa. Eso se hace con una rutina estándar llamada LDIRVM que se ubica en la dirección fija &H005C de la BIOS.

Otras rutinas, como el reseteo y limpiado de la pantalla, etc. corresponden a posiciones fijas de la BIOS. Se pueden consultar todas las rutinas existentes en esta magnífica página:

MSX BIOS calls

Primera versión funcional

El siguiente listado corresponde a la primera versión del esqueleto, consistente solo en cargar un tablero lleno de asteriscos como generación 0, pintar el título del programa y otras etiquetas de estado y terminar esperando la pulsación de una tecla.

Pantalla del programa en esta fase

   10       ORG  &HA500
   20 ; Llamadas del sistema (https://map.grauw.nl/resources/msxbios.php)
   30 CHGMOD:   EQU  &H5F              ; Cambiar modo de pantalla
   40 ERAFNK:   EQU  &HCC              ; Quitar teclas de funcion
   50 LDIRVM:   EQU  &H5C              ; Copiar bloque a VRAM
   60 CHGET:    EQU  &H9F              ; Esperar una tecla
   70 POSIT:    EQU  &HC6              ; Posicionar cursor
   80 CHPUT:    EQU  &HA2              ; Escribir caracter
   90 ; Reiniciar pantalla (SCREEN 0)
  100       LD   A,0
  110       CALL CHGMOD
  120       CALL ERAFNK
  130 ; Escribir estado
  140       LD   L,1               ; Linea 1
  150       LD   H,26              ; Columna 26
  160       CALL POSIT
  170       LD   HL,TIT1
  180       CALL IMPETI
  190       LD   L,2               ; Linea 2
  200       LD   H,26              ; Columna 26
  210       CALL POSIT
  220       LD   HL,TIT2
  230       CALL IMPETI
  240       LD   L,3               ; Linea 3
  250       LD   H,26              ; Columna 26
  260       CALL POSIT
  270       LD   HL,TIT3
  280       CALL IMPETI
  290       LD   L,5               ; Linea 5
  300       LD   H,26              ; Columna 26
  310       CALL POSIT
  320       LD   HL,ETI1
  330       CALL IMPETI
  340       LD   HL,0
  350       CALL IMPNUM
  360       LD   L,7               ; Linea 7
  370       LD   H,26              ; Columna 26
  380       CALL POSIT
  390       LD   HL,ETI2
  400       CALL IMPETI
  410       LD   L,8               ; Linea 8
  420       LD   H,26              ; Columna 26
  430       CALL POSIT
  440       LD   HL,ETI3
  450       CALL IMPETI
  460 ; Volcar generacion a pantalla
  470       LD   HL,GEN0           ; TODO hacer bucle y HL ira apuntando a cada generacion
  480       CALL PINTAR
  490 ; Final
  500       CALL CHGET
  510       RET 
  520 ; Rutina de pintado del tablero
  530 PINTAR:   ; viene HL apuntando a la casilla Dimension
  540       LD   A,(HL)
  550       LD   C,A
  560       LD   B,0
  570       INC  HL
  580       LD   DE,0
  590       PUSH HL
  600       PUSH BC
  610       PUSH DE
  620       PUSH AF
  630 LINEA:    CALL LDIRVM
  640 ; Sumar 40 a DE para saltar a la siguiente linea de pantalla
  650       POP  AF
  660       POP  HL                ; Intermediario para el valor de DE
  670       LD   BC,40
  680       ADD  HL,BC
  690       LD   D,H
  700       LD   E,L
  710       POP  BC
  720       POP  HL
  730 ; Sumar dimension para siguiente linea del tablero
  740       ADD  HL,BC
  750       PUSH HL
  760       PUSH BC
  770       PUSH DE
  780 ; Decrementar A y bucle si !=0
  790       DEC  A
  800       PUSH AF
  810       CP   0
  820       JR   NZ,LINEA
  830       POP  AF
  840       POP  DE
  850       POP  BC
  860       POP  HL
  870       RET 
  880 ; Rutina imprimir etiqueta en HL
  890 IMPETI:   LD   A,(HL)
  900       CP   255               ; Buscar fin de cadena
  910       RET  Z
  920       INC  HL
  930       CALL CHPUT
  940       JR   IMPETI
  950 ; Rutina imprimir numero decimal,
  960 ; https://wikiti.brandonw.net/index.php?title=Z80_Routines:Other:DispHL
  970 IMPNUM:   LD   BC,-10000
  980       CALL NUM1
  990       LD   BC,-1000
 1000       CALL NUM1
 1010       LD   BC,-100
 1020       CALL NUM1
 1030       LD   C,-10
 1040       CALL NUM1
 1050       LD   C,-1
 1060 NUM1: LD   A,"0"-1
 1070 NUM2: INC  A
 1080       ADD  HL,BC
 1090       JR   C,NUM2
 1100       SBC  HL,BC
 1110       CALL CHPUT
 1120       RET 
 1130 TIT1: DEFM "JUEGO DE"
 1140       DEFB 255               ; Fin de cadena
 1150 TIT2: DEFM "LA VIDA"
 1160       DEFB 255
 1170 TIT3: DEFM "por ElPamplina"
 1180       DEFB 255
 1190 ETI1: DEFM "Gen: "
 1200       DEFB 255
 1210 ETI2: DEFM "ESP: Seguir"
 1220       DEFB 255
 1230 ETI3: DEFM "ESC: Salir"
 1240       DEFB 255
10000 ; Datos a rellenar
10010 GEN0: DEFB 24                ; El primer byte indica la dimension
10020       DEFM "************************"
10030       DEFM "************************"
10040       DEFM "************************"
10050       DEFM "************************"
10060       DEFM "************************"
10070       DEFM "************************"
10080       DEFM "************************"
10090       DEFM "************************"
10100       DEFM "************************"
10110       DEFM "************************"
10120       DEFM "************************"
10130       DEFM "************************"
10140       DEFM "************************"
10150       DEFM "************************"
10160       DEFM "************************"
10170       DEFM "************************"
10180       DEFM "************************"
10190       DEFM "************************"
10200       DEFM "************************"
10210       DEFM "************************"
10220       DEFM "************************"
10230       DEFM "************************"
10240       DEFM "************************"
10250       DEFM "************************"

Que haga falta semejante cantidad de código solo para pintar una carátula fija en pantalla nos hace a la idea de lo laborioso que es programar en ensamblador. Estamos usando las instrucciones más básicas y contamos con un reducidísimo conjunto de registros para operar.

Cálculo de adyacentes

Al ser la memoria una estructura lineal, con solo una coordenada (la dirección de memoria), la localización de las celdas adyacentes por arriba y por debajo requiere ciertos cálculos. Concretamente, siendo Dim la dimensión del tablero, las coordenadas virtuales en el tablero X e Y (empezando por cero hasta Dim-1), y siendo B la dirección base de la primera celda, el cálculo de la posición de memoria de una celda concreta sería:

`P = B + X + Y*Dim`

Esto nos obliga a hacer varios cálculos para encontrar cada celda. Siendo 8 celdas adyacentes, serían 8 multiplicaciones y 24 sumas para localizarlas a todas. Vamos entendiendo por qué el programa BASIC se hace tan lento. No olvidemos que el Z80 no tiene operaciones nativas de multiplicación ni división (exceptuando los desplazamientos de bits).

Para evitar este engorro, se podría mantener un puntero separado por cada fila implicada, e ir incrementando poco a poco. Como la máquina solo posee tres registros dobles, es casi imposible mantener esos punteros sin llevarlos a memoria RAM, lo cual penalizaría el rendimiento.

Las operaciones de suma y resta sí se pueden hacer directamente en los registros de la máquina, por lo que me gusta mucho más la solución de usar un único puntero, idealmente el HL, y hacer HL-Dim para la fila anterior y HL+Dim para la posterior. Aún tendré que acceder a RAM para leer el valor de Dim, pero siempre serán menos que guardando tres punteros.

Algo que me va a traer de cabeza es el caso particular de las celdas en los bordes. Concretamente, cuando estamos en la primera o última fila o columna hay que ignorar las celdas adyacentes por cada lado. Algo que me puede obligar a hacer muchas más comparaciones y cálculos de los deseados. En un lenguaje de alto nivel no sería mucho problema, pero con el reducísimo juego de registros que tenemos hay que buscar una solución más imaginativa.

Algo que se aprende con la experiencia al programar es que si las condiciones de tu algoritmo son demasiadas, lo que tienes que hacer es quitar condiciones de partida. En este caso, se me ocurre rellenar los bordes exteriores el tablero con celdas vacías. De esta forma, gastaremos más memoria para guardar el tablero, pero el algoritmo no tendrá que preocuparse por los bordes.

Para distinguir este borde del verdadero tablero, lo rellenaré con otro carácter distinto del espacio, pero que coincida en que el bit 1 lo tenga a cero. Así será indistinguible para el algoritmo. Escojo el guión (ASCII 2D, 00101101).

Otro lugar especial del borde es la casilla justo después de la última celda. Es el punto donde termina el tablero y hay que salir del bucle principal. Lo marco con un símbolo dólar, que también tiene el bit 1 a 0 (ASCII 24, 00100100).

En resumen, un tablero vacío de 7x7 se guardaría así:

- - - - - - - - -
-               -
-               -
-               -
-               -
-               -
-               -
-               $
- - - - - - - - -

Así he conseguido que todas las celdas tengan ocho vecinos válidos y no tengo que preocuparme de los bordes. Además puedo detectar muy fácilmente si he llegado al final del tablero. A cambio, gasto `Dimensión*4-4` bytes extra en almacenar el tablero, pero vale la pena. Con este nuevo modelo de tablero, mi MSX me va a permitir hasta 23 generaciones simultáneamente en memoria.

Rutina principal

La rutina SIGGEN es el corazón del programa, y he puesto abundantes comentarios para que se entienda lo que hace.

Como suele ser habitual, la mayor parte de los comandos son operaciones LD moviendo datos de unos registros a otros, y muchos PUSH y POP para liberar los registros, usarlos para otros cálculos y luego recuperar sus valores. Recordemos que el Z80 solo tiene tres registros de 16 bits (BC, DE y HL), y con eso tenemos que apañarnos para manejar las direcciones de memoria. En realidad hay otro juego de registros auxiliares, pero no se pueden usar simultáneamente, lo cual les resta utilidad.

Otra curiosidad es que no todas las operaciones existen para todos los registros, y hay que usar alternativas. Por ejemplo, para hacer `LD HL,DE` tendremos que hacerlo por parejas: `LD H,D` y `LD L,E`.

Tuve que lidiar además con un problema extra que me trajo de cabeza hasta que me di cuenta de lo que pasaba con los puñeteros saltos que no se hacían bien. Resultó que el ensamblador RSC II tiene un bug por el cual, a partir de cierto momento que no supe concretar, no calcula bien las etiquetas y las coloca un byte adelantadas. Lo solucioné incluyendo una operación vacía NOP en cada etiqueta. Además procuré ahorrarme todas las etiquetas posibles, haciendo los saltos cortos con direcciones relativas (por ejemplo, `JR Z,1` para el equivalente a un IF que se salta la siguiente instrucción).

Este es el código de la rutina SIGGEN:

SIGGEN: NOP ; Calculo de la siguiente generacion
; HL=Posicion siguiente generacion
; C=Dimension
; DE=Posicion actual generacion
LD (HL),C ; Copio dimension
; Los siguientes dimension+3 bytes del borde van con guiones
LD A,3
ADD A,C
LD B,A
LD A,"-"
BUCLE1: NOP
INC DE ; Menos eficiente que sumar de golpe, pero mas facil
INC HL
LD (HL),A
DJNZ BUCLE1
INC C   ; C se queda con dimension+1 por conveniencia
; El bucle "duro" empieza ahora, donde hay que optimizar mas
BUCLE2: NOP
INC DE ; Apunta ahora a la celda actual
INC HL ; Apunta a la celda futura
; Si estamos en un guion, lo saltamos
LD A,(DE)
CP "-"
JR Z,BUCLE2
; Si estamos en un $, es el final
CP "$"
JR Z,FINAL
; Contar cuantos vecinos tiene DE
; Fila superior: restamos dimension+3
PUSH HL
LD H,D
LD L,E
DEC HL
DEC HL
OR A  ; Limpiar carry para la resta
SBC HL,BC
; Comprobar las tres celdas de la fila superior cuantas de ellas
; tienen activado el bit 1
LD A,0
LD B,3
BUCLE3: NOP
BIT 1,(HL)
JR Z,1   ; Salto la orden INC A
INC A
INC HL
DJNZ BUCLE3
; Vecinos de la misma fila. 
; Sumo dimension+1 (C) para el vecino derecho
ADD HL,BC
BIT 1,(HL)
JR Z,1
INC A
; Vecino izquierdo
DEC HL
DEC HL
BIT 1,(HL)
JR Z,1
INC A
; Fila siguiente (sumo dimension+2)
ADD HL,BC
INC HL
LD B,3
BUCLE4: NOP
BIT 1,(HL)
JR Z,1
INC A
INC HL
DJNZ BUCLE4
POP HL
; Ya tenemos el conteo en A y la futura celda en HL. 
; Aplicamos las tres reglas de Conway.
; 1) Si tiene exactamente 3 vecinos vivos, la celda estara viva
CP 3
JR NZ,SALTO1
LD (HL),"*"
JR SALIDA
SALTO1: NOP
; 2) Si tiene 2 vecinos vivos y su estado actual es viva, seguira viva
CP 2
JR NZ,SALTO2
LD A,(DE)
BIT 1,A
JR Z,SALTO2
LD (HL),A
JR SALIDA
SALTO2: NOP
; 3) En cualquier otro caso, esta muerta
LD (HL)," "
SALIDA: NOP
JR BUCLE2
FINAL: NOP
; Copiar la fila de borde inferior
LD B,C
INC B
INC B   ; B=dimension+3
SALTO3: NOP
LD A,(DE)
LD (HL),A
INC HL
INC DE
DJNZ SALTO3
; DE=nueva actual generacion
; HL=nueva siguiente generacion
RET 

Cargador BASIC

Para facilitar la introducción de la generación cero, he preparado un programa lanzador en BASIC, que carga el código máquina mediante etiquetas DATA, y tiene espacio para definir la dimensión y el tablero inicial.

Una vez cargado todo en memoria, utiliza el comando USR para lanzar el código.

CLEAR 200,&HA500
SCREEN 0
WIDTH 40
FOR D=&HA500 TO &HA6A1
READ X
POKE D,X
NEXT D
READ DI
PRINT "DIMENSION: "; DI
POKE &HA6A6,DI
D=&HA6A7
FOR P=1 TO DI+2
POKE D,ASC("-")
D=D+1
NEXT P
FOR LIN=1 TO DI
READ L$
PRINT L$
POKE D,ASC("-")
D=D+1
FOR P=1 TO DI
POKE D,ASC(MID$(L$,P,1))
D=D+1
NEXT P
IF LIN=DI THEN POKE D,ASC("$") ELSE POKE D,ASC("-")
D=D+1
NEXT LIN
FOR P=1 TO DI+2
POKE D,ASC("-")
D=D+1
NEXT P
DEFUSR=&HA500
X=USR(0)
DATA 62,0,205,95,0,205,204,0,46,1,38,26,205,198,0,33,96,166,205
DATA 187,165,46,2,38,26,205,198,0,33,106,166,205,187,165,46,3,38
DATA 26,205,198,0,33,115,166,205,187,165,46,5,38,26,205,198,0,33
DATA 131,166,205,187,165,46,7,38,26,205,198,0,33,138,166,205,187
DATA 165,46,8,38,26,205,198,0,33,151,166,205,187,165,33,0,0,34
DATA 163,166,33,166,166,229,0,46,5,38,31,205,198,0,42,163,166,205
DATA 198,165,42,163,166,35,34,163,166,225,229,205,137,165,209,205
DATA 238,165,213,205,159,0,254,27,32,219,209,201,0,126,79,6,0,9,17
DATA 4,0,25,17,0,0,229,197,213,245,0,205,92,0,241,225,1,40,0,9,84
DATA 93,193,225,9,35,35,229,197,213,61,245,254,0,32,231,241,209
DATA 193,225,9,35,201,0,126,254,255,200,35,205,162,0,24,246,0,1,240
DATA 216,205,224,165,1,24,252,205,224,165,1,156,255,205,224,165,14
DATA 246,205,224,165,14,255,0,62,47,0,60,9,56,252,237,66,205,162
DATA 0,201,0,113,62,3,129,71,62,45,0,19,35,119,16,251,12,0,19,35
DATA 26,254,45,40,249,254,36,40,76,229,98,107,43,43,183,237,66,62
DATA 0,6,3,0,203,78,40,1,60,35,16,248,9,203,78,40,1,60,43,43,203
DATA 78,40,1,60,9,35,6,3,0,203,78,40,1,60,35,16,248,225,254,3,32
DATA 5,54,42,24,17,0,254,2,32,9,26,203,79,40,4,119,24,4,0,54,32,0
DATA 24,170,0,65,4,4,0,26,119,35,19,16,250,201,0,74,85,69,71,79,32
DATA 68,69,255,0,76,65,32,86,73,68,65,255,0,112,111,114,32,69,108
DATA 80,97,109,112,108,105,110,97,255,0,71,101,110,58,32,255,0,69
DATA 83,80,58,32,83,101,103,117,105,114,255,0,69,83,67,58,32,83
DATA 97,108,105,114,255
'Dimension
DATA 24
'Celdas
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "
DATA "                        "

Resultado

Pantalla inicial con tres "organismos", uno cíclico y dos móviles.

Evolución de esos organismos en 23 generaciones.

Descargables

Programa completo en ensamblador (GPLv3).

Cargador BASIC en formato audio para CLOAD (GPLv3).

Retos

Las siguientes mejoras le vendrían muy bien al programa:

- Guardar las generaciones cíclicamente para que se pueda hacer tantas generaciones como se quiera.

- Que se pueda volver atrás tantas veces como generaciones haya en memoria simultáneamente (idealmente hasta 23).

- Utilizar la pantalla en modo gráfico para poder representar tableros mucho más grandes y con mejor aspecto visual.