#2 Un mundo, partidas guardadas, repeticiones y un error escondido en el formato de guardado
· Nick
Desde la primera entrada, el núcleo ha ganado un mundo donde poner cosas, archivos donde guardarlo y una forma de demostrar que dos ejecuciones coinciden. Sigue sin haber gráficos ni personajes. Casi toda esta entrada es fontanería, y una parte es un error que resultó merecer su propia historia.
Un mundo hecho de baldosas
El mundo es una cuadrícula de baldosas de 1×1 m en niveles discretos, numerados desde cero hacia arriba. Una pared o una puerta es una baldosa; una escalera será un enlace especial entre dos niveles, pero todavía no se construyen escaleras. Internamente, el mundo es un conjunto de capas planas (suelo, estructura, indicadores de estructura) para todo el mapa, más una capa derivada de transitabilidad. El mapa se gestiona en bloques de 16×16 con contadores de versión, de modo que un renderizador o una búsqueda de caminos puedan preguntar “¿ha cambiado esta zona?” sin comparar baldosas.
El tamaño del mundo se elige al crearlo. Se construye con comandos que toman un rectángulo: colocar un suelo, una pared o una puerta en una zona, o quitarlos. Si parte del rectángulo está bloqueada, el resto se construye igualmente y el comando informa de lo que omitió. Por ahora hay tres tipos de baldosa integrados: suelo, pared, puerta. A partir de aquí, el mapa forma parte del hash del mundo y de las partidas guardadas. Se contrasta con un modelo de referencia lento y obviamente correcto sobre miles de comandos aleatorios.
Partidas guardadas y repeticiones
Una partida guardada y una repetición comparten un mismo contenedor de archivo. Está dividido en secciones etiquetadas, cada una con un tipo y una versión, de modo que las partes nuevas del mundo (personajes, necesidades, relaciones) puedan convertirse más adelante en secciones nuevas sin romper los archivos antiguos. Hay sumas de comprobación del encabezado y del cuerpo, el cuerpo está comprimido, y cada tamaño se comprueba contra un límite antes de reservar memoria, de modo que un archivo corrupto o malintencionado no pueda hacer que el juego se coma toda tu RAM. Los tipos de baldosa se guardan por nombre y no por número, así que una partida guardada sigue funcionando aunque cambie la lista de tipos.
Una repetición es lo que quería desde el principio: una semilla más el registro de comandos. Al grabar, el núcleo también escribe un hash de control cada 100 ticks. Al reproducir una repetición, compara el resultado de cada comando y cada hash de control por el camino, y se detiene en el primer tick en que discrepan. Además, una repetición recuerda con qué compilación del núcleo se grabó, de modo que una repetición de otra compilación se rechaza en lugar de divergir en silencio.
El propio núcleo nunca toca archivos: lee y escribe flujos, y es el juego quien decide dónde viven las partidas guardadas. Esa frontera la impone la misma maquinaria de API prohibidas de antes.
Aún no está hecho: una herramienta de línea de comandos que reproduzca un archivo de repetición, y la parte del juego relativa al guardado (una carpeta de guardado, autoguardados, escritura segura).
Demostrar que dos ejecuciones coinciden
La prueba de determinismo ahora ejecuta un escenario en dos procesos distintos: una semilla, un guion de comandos de construcción (algunos deliberadamente inválidos) y un sistema de prueba que consume todos los flujos aleatorios. Cada proceso lo ejecuta de tres maneras — de principio a fin, pasando por un guardado y una restauración, y pasando por una repetición de los bytes grabados — y compara hashes en cada tick. Después, los dos procesos comparan sus hashes entre sí cada 10 ticks.
La rompí a propósito para asegurarme de que puede fallar. Un Random sin semilla colado en el tick puso la prueba en rojo. Un hash de cadena aleatorizado, que .NET inicializa de forma distinta en cada proceso, pasó inadvertido dentro de un solo proceso y solo se detectó al comparar dos. Ese es todo el motivo de usar dos procesos.
La regla que dejé escrita es que una misma compilación del núcleo debe dar los mismos hashes en procesadores x64 y arm64, de modo que una repetición grabada en un Mac con chip de Apple tiene que poder reproducirse en un PC con Windows. Existen hashes de referencia fijados y archivos de ejemplo de guardado y repetición precisamente para esa comprobación. La trigonometría y las exponenciales en coma flotante difieren entre plataformas, así que están vetadas en todo lo que afecte al mundo; necesitaré un sustituto determinista la primera vez que un sistema las requiera. Estos archivos de referencia solo cambian de forma deliberada: si un hash se desvía, hay que explicar el cambio antes de aceptarlo.
Un benchmark capaz de decir que no
Ahora hay un ejecutor sin interfaz que toma un escenario, una semilla y un número de ticks, e imprime un informe JSON: la mediana y el percentil 99 del tiempo por tick, el número de agentes, el hash del mundo y cuántos bytes se reservaron por tick. El primer escenario construye un mapa de 512×512 en 16 niveles con unos 52.000 comandos de construcción.
La línea base se guarda por máquina, porque los tiempos de un portátil no significan nada en un sobremesa. Una regresión es una mediana más de un 10 % por encima de la línea base o un percentil 99 más de un 35 % por encima, y solo si el aumento supera los 0,05 ms, ya que un tick casi vacío está en el límite de resolución del temporizador. Una condición se cumple en todas las máquinas: cero bytes reservados por tick. Debo ser sincero sobre lo que esto mide hoy. Sin sistemas ni personajes, un tick tarda unos 35 nanosegundos, así que la condición de tiempo está prácticamente dormida hasta que lleguen los primeros sistemas reales en el hito de movimiento. La condición de reservas es la que trabaja ahora.
La prueba intermitente que no era intermitente
Mientras construía el benchmark, una prueba del formato de guardado empezó a fallar en algunas compilaciones aunque el núcleo no había cambiado. La prueba voltea cada byte de una repetición comprimida, uno por uno, y exige que el cargador rechace todos. En una compilación se aceptó un byte cercano al final del archivo. En la compilación anterior, la misma prueba pasaba.
El motivo de que apareciera y desapareciera: una repetición lleva el identificador de la compilación que la escribió, así que los bytes comprimidos cambian de una compilación a otra, y con ellos el byte que se voltea. El agujero siempre estuvo ahí; la prueba solo lo notó cuando los dados salieron así.
El agujero en sí: la suma de comprobación cubría los datos después de descomprimirlos, y el descompresor de .NET resultó ser permisivo. Acepta un flujo que nunca dice que ha terminado e ignora todo lo que haya después del final. Voltear un bit de relleno en el último byte produce un flujo distinto pero igualmente válido que se descomprime en los mismos datos, así que la suma de comprobación seguía coincidiendo y un archivo dañado se cargaba como bueno.
La solución es una nueva versión del contenedor. La suma de comprobación ahora cubre los bytes tal como están almacenados en el archivo y se verifica antes de descomprimir, y la descompresión debe terminar exactamente donde indica el encabezado. Un cuerpo vacío se almacena sin comprimir, ya que el compresor no escribe nada para una entrada vacía. Las comprobaciones usan ahora flujos comprimidos fijos que no dependen de la compilación, además de fuzzing con archivos dañados. Los archivos de ejemplo se regeneraron para la nueva versión; los hashes del mundo no cambiaron.
Por otro lado
El sitio web, el que estás leyendo, ahora muestra dos contadores de visitas en la portada: visitantes únicos de todos los tiempos y de las últimas 24 horas. Se cuentan a partir del registro de páginas del servidor web, sin JavaScript, sin cookies y sin servicios externos. Una dirección se guarda solo como un hash con sal, los bots se filtran por su user agent, y las cifras van unos minutos por detrás. Varias personas tras una misma red cuentan como una.
Lo siguiente
El primer hito, el esqueleto del núcleo, está terminado. Lo siguiente es M1, la vista de construcción en Godot: paredes, puertas y salas a partir del modelo de mundo, corte por plantas, una cámara que gira en pasos de 90° y cubos simples en lugar de arte. El trabajo aún no ha empezado. Consulta la hoja de ruta.