Volver

Castlevania: Symphony of the Night ejecutándose de forma nativa en el ESP32-S3

La consola portátil XIAO ESP32S3 ejecutando Castlevania: Symphony of the Night en el Laboratorio de Alquimia

En el artículo de Metal Gear Solid mostré la consola portátil que construí en torno a un XIAO ESP32S3 Sense. Castlevania: Symphony of the Night (SOTN) es el otro juego de PlayStation que ejecuta, y el que llegó primero: estaba funcionando a 60 fps en una placa de desarrollo Waveshare ESP32-S3 antes de que existiera la portátil, y después se trasladó a ella.

Fue posible por la misma razón que Metal Gear Solid. La descompilación comunitaria de SOTN se compila como C portable, así que el juego puede compilarse para el ESP32-S3 en lugar de emularse.

Jugabilidad en la portátil: míralo en YouTube.

Este artículo cubre cómo está estructurado el port, cómo encaja el juego en la memoria del SoC, cómo el rasterizador por software alcanzó los 60 fps, los errores que el hardware original había estado ocultando y una consola portátil que construí en torno a un Seeed XIAO ESP32S3 Sense.

Por qué es posible un port nativo

Emular una PlayStation en un ESP32-S3 está fuera de alcance. Un emulador tiene que interpretar una CPU MIPS y una GPU instrucción a instrucción, lo que necesita varias veces el rendimiento que tiene el SoC.

Hacer un port es diferente. El proyecto de descompilación de SOTN ha reconstruido el juego como código fuente en C que se compila a un ejecutable de PC. La lógica del juego es C corriente, y lo único que lo ata a la consola es el SDK de Sony: las llamadas a la biblioteca que dibujan polígonos, suben texturas, leen el mando y reproducen sonidos. La compilación para PC sustituye ese SDK por psyz, una reimplementación sobre SDL.

El trabajo, entonces, tiene tres partes:

  1. Compilar el juego para Xtensa.
  2. Sustituir el backend de PC de psyz por uno para el SoC.
  3. Hacer que todo encaje.

El SoC objetivo es el ESP32-S3:

Arquitectura

Las capas, desde el juego hasta el hardware:

  1. Juego: motor descompilado, código del jugador y las armas, escenarios
  2. psyz: reimplementación del SDK con PSYZ_RENDERER=soft
  3. Capa de plataforma (esp32/main): VSync, contadores raíz, entrada, archivos FAT, audio, salida al LCD
  4. Hardware del ESP32-S3: núcleos LX7, SRAM, PSRAM, flash, LCD SPI

Las capas del port y los dos núcleos: el juego y el rasterizador en el núcleo 0, la salida al LCD y el audio en el núcleo 1, y el panel y la microSD compartiendo SPI2 en la portátil

Dos decisiones de diseño condicionaron todo lo demás.

La VRAM sigue siendo el propio búfer del juego. La PlayStation tiene 1 MB de memoria de vídeo, 1024×512 píxeles en color de 16 bits. La descompilación ya la modela como un array simple. El rasterizador dibuja directamente en ella (en PSRAM), y el LCD se alimenta desde ella. No hay copias en la sombra ni conversiones de formato, salvo la final al RGB565 del panel.

Los escenarios se enlazan de forma estática. En un PC, cada zona del castillo es una DLL que se carga bajo demanda. El ESP32-S3 no tiene enlazador dinámico, así que cada zona se compila dentro del firmware, y una pequeña tabla asocia el nombre de un escenario con su función de inicialización. Esa decisión causó problemas dos veces, como se describe en “Errores que la PlayStation perdona” y “Añadir un escenario” más abajo.

Encajar un juego de PlayStation en 512 KB

Alucard junto a una de las estatuas del laboratorio

El primer enlazado falló por 3,34 MB de RAM interna. La mayor parte de eso no era culpa del juego:

Esa última tiene una trampa: algunos datos “de solo lectura” se escriben. El SDK actualiza las cabeceras de los bancos de sonido in situ cuando se abre un banco. Declaradas const, viven en flash, y escribir en flash a través de la caché provoca un fallo grave (“Dbus write to cache”). El patrón que funciona es mantener un maestro const en flash, copiarlo a un búfer en PSRAM al arrancar y dejar que el juego escriba en la copia.

Tras 28 rondas de enlazado, los números eran:

El rasterizador por software, y llegar a 60 fps

Las llamas del laboratorio: efectos semitransparentes dibujados por el rasterizador por software

SOTN es un juego 2D, y su mezcla de primitivas es pequeña: quads texturizados y Gouraud, sprites de 16×16 para las capas de tiles, rectángulos y líneas. No hay 3D ni motor de transformación de geometría, lo que hizo realista un rasterizador por software.

El rasterizador es bit a bit exacto respecto a la compilación de referencia de la GPU. Comparé las sumas de comprobación de fotogramas entre la compilación de PC y la placa después de cada optimización.

La mayor parte del tiempo de fotograma se iba en accesos a memoria. Cada píxel texturizado lee un téxel y una entrada de paleta de la PSRAM y escribe un píxel en la PSRAM. Las optimizaciones que importaron todas reducen ese tráfico:

PasoResultado
Avance de aristas de triángulos: divisiones, luego acumuladores de 64 bits, luego 32 bits 16.16Los 64 bits eran más lentos en un núcleo de 32 bits; se quedó 16.16
Salida al LCD movida al núcleo 1, alimentada por una notificaciónEl juego nunca espera al SPI
Caché de paleta en RAM interna, bucles de rasterización en IRAMWarp Room a 31 fps
Ruta rápida de la capa de tiles: 4 téxeles por lectura, pares de píxeles como escrituras de 32 bitsDe 10,4 ms a 6,1 ms por fotograma
Salida desde el búfer que el juego acaba de terminar de dibujarSin copia, un fotograma menos de latencia
PSRAM y flash de 80 a 120 MHzDe 19,4 ms a 16,4 ms por fotograma: 60 fps

También probé una caché de filas de textura en el rasterizador de triángulos y la revertí. No aportó nada, porque GCC ya había elevado las cargas.

El cambio de sacar la imagen desde el búfer terminado necesita algo de explicación. La fuente obvia para la pantalla es el área de visualización actual del SDK, pero va un fotograma por detrás: en el VSync nombra el búfer en el que el juego está a punto de dibujar. Sacar desde ahí significaba que el DMA y el rasterizador se peleaban por la misma mitad de la VRAM durante todo el fotograma. Leer el origen de visualización desde la propia estructura de búfer del juego hace que ambos trabajen sobre mitades opuestas por construcción.

Errores que la PlayStation perdona

La PlayStation no tiene protección de memoria. Una lectura perdida devuelve lo que haya allí, y una escritura perdida aterriza en memoria que a menudo nadie comprueba. La versión para PC también oculta en su mayoría estos errores, porque sus estáticos son grandes y tolerantes. El ESP32-S3 tiene una MMU, memoria ajustada y vecinos que importan, así que portarlo resultó ser una forma muy buena de encontrar errores latentes.

Luchando en el Laboratorio de Alquimia en la consola portátil

Cómo los encontré

Un backtrace de pánico nombra a la víctima, no al culpable. La herramienta que funcionó fue OpenOCD a través del USB-JTAG integrado del SoC, junto con GDB:

  1. Reiniciar y detener el SoC mediante OpenOCD.
  2. Establecer un punto de interrupción en el manejador de pánico.
  3. Cuando se activa, decodificar el PC real desde el marco de pánico.
  4. Decodificar siempre contra el ELF exacto que se flasheó, porque las direcciones cambian en cada recompilación.

Animación de paleta fuera de límites

El Laboratorio de Alquimia solicita la animación de paleta (tileset & 0xFF) + 0x7FFF | 0x4000, que indexa la entrada 2 de la tabla de paletas de la etapa. La tabla tiene una entrada. La lectura fuera de límites aterrizó en la tabla de bancos de sprites adyacente, que luego se registró como descriptor de animación de paleta y se escribía cada fotograma.

La corrección rechaza descriptores cuya extensión excede el búfer de paletas. El mismo comportamiento indefinido existe en el código original; el PC lo absorbe en un BSS grande.

Dos etapas, un solo HitDetection

Con las etapas enlazadas estáticamente, 122 símbolos globales estaban definidos en más de una etapa. Los encabezados compartidos implementan cosas como colisiones y actualizaciones de entidades, y en la PlayStation solo se cargaba una etapa a la vez. Los archivos estáticos permiten que el enlazador resuelva silenciosamente cada nombre a una definición. El Laboratorio de Alquimia ejecutaba el HitDetection de la Sala de Teletransporte contra sus propias tablas de entidades, y la corrupción apareció una capa más tarde.

La corrección es una lista generada de cada símbolo que comparten dos etapas, renombrado por etapa con defines -D.

Salas que mutan su propio mapa

Las puertas y las paredes rompibles escriben en el mapa de tiles de la sala. Con los mapas generados en flash, la primera puerta hizo crashear el juego. La corrección es un único punto donde la capa de primer plano de la sala activa se copia a un búfer de PSRAM escribible.

El nombre del enemigo

El cuadro del nombre del enemigo en la parte inferior de la pantalla, dibujado por el mismo código BottomCornerText que solía desbordar la pila

Con la reliquia del Pergamino de Hada, el juego imprime el nombre del enemigo al que golpeas. BottomCornerText recorre la cadena buscando el terminador FF 00 de la PlayStation. Las cadenas del PC son cadenas C normales, así que el recorrido se pasó del final y sobrescribió un búfer de pila de 64 bytes. El síntoma era “se congela en cuanto toco a un enemigo”. La corrección limita el recorrido.

Una condición de carrera que no existía en la PlayStation

El audio se ejecuta en el segundo núcleo, y la extracción de audio estaba avanzando los contadores raíz. Esos contadores disparan el manejador de VSync del juego, las subidas de texturas y los callbacks de la cola de la GPU. En la PlayStation esas son interrupciones en la misma CPU. Aquí se ejecutaban concurrentemente con el juego en el otro núcleo.

La corrección acumula los tics de los contadores en el núcleo 1 y dispara los manejadores en el hilo del juego.

Entrada que nunca llegaba

El informe era “no puedo saltar”. Había tres errores apilados:

La consola portátil: puesta en marcha del hardware en un XIAO ESP32S3 Sense

La consola portátil terminada vista desde arriba: panel ILI9341, stick de gimbal de dron, botones y el XIAO sobre placa perforada

La parte trasera de la consola portátil: cableado punto a punto en la placa perforada y la propia ranura SD del panel (sin usar). El módulo de cámara del XIAO, retirado, está a su lado

La parte frontal de la consola portátil junto al módulo de cámara del XIAO, que el port no usa y fue retirado

El objetivo era una consola portátil. Es el mismo hardware que después ejecutó Metal Gear Solid: SOTN fue el primer juego en ella. Las piezas:

El XIAO tiene once pines utilizables. El panel necesita cinco (SCK, MOSI, MISO, CS, DC) más una línea de reset, y el stick necesita dos pines ADC. Eso deja tres para ocho botones.

Seis de los botones comparten un pin ADC mediante una escalera de resistencias: una pull-up de 10k y, por botón, una resistencia a tierra (0 Ω, 2k2, 4k7, 10k, 22k, 47k). Cada botón produce un voltaje diferente. La limitación es que dos botones pulsados a la vez se leen como el inferior. El par que cada juego mantiene pulsado junto (ataque y salto en SOTN) recibe los dos pines dedicados restantes.

El diagrama de cableado completo, la lista de piezas y el mapa de botones están en la guía de hardware del repositorio.

Cableado de la consola portátil, dibujado con el arte de componentes de Velxio: XIAO ESP32S3 Sense, panel ILI9341, stick analógico, escalera de resistencias de seis botones y dos botones directos

La puesta en marcha fue su propia lista de lecciones. Antes de tocar el juego, escribí un firmware de pruebas independiente que dibuja barras de color y muestra cada botón y el stick en pantalla:

La consola portátil: pantalla, entrada y un crash detectado por un watchpoint

Todo en la tarjeta SD

8 MB de flash no pueden contener una app de 3 MB junto a 3,7 MB de datos del juego, así que en la consola portátil todos los datos vienen de la tarjeta microSD. Esa tarjeta comparte el bus SPI con el panel, lo que más tarde provocó su propio crash (ver “El bus SPI compartido” más abajo).

Dos trampas específicas de esta placa

GPIO43 es a la vez el pin de datos/comandos del panel y el TX del UART0. El código de Waveshare instalaba el driver del UART para su consola serie. Hacer eso devuelve el pin silenciosamente al UART, y el panel deja de distinguir comandos de píxeles. La consola portátil usa USB nativo solo para su consola.

printf por USB nativo se bloquea cuando nadie está leyendo. Con un PC conectado nunca se nota. Desenchufado, el búfer de transmisión se llena en unos segundos y el juego se detiene dentro de un printf, en un dispositivo pensado para funcionar desenchufado. Toda la salida, incluido el propio logging de ESP-IDF, pasa ahora por un wrapper que escribe con un timeout de cero y descarta lo que nadie lee.

Parpadeo y luego rectángulos deslizantes

La primera compilación parpadeaba. Supuse que el reloj SPI era demasiado lento y lo subí de 40 a 80 MHz. Eso ayudó un poco, pero instrumentar el scanout mostró lo que realmente estaba pasando:

Línea temporal: el juego dibuja un frame cada 16,7 ms en los búferes A y B mientras el panel tarda unos 24 ms en recibir uno, así que el juego redibuja un búfer que la DMA todavía está enviando

Esto era un problema de coherencia de búferes. La solución fue la ruta de copia que el port de Waveshare había conservado como red de seguridad: copiar el frame terminado y luego enviar la copia. Mi primera versión tenía dos errores:

Incluso después de ambas correcciones, los rectángulos seguían ahí. Bajar el reloj SPI de nuevo a 40 MHz los hizo desaparecer: los cables jumper no aguantaban 80 MHz, y un deslizamiento de un byte desplaza toda una banda de 8 líneas. El reloj SPI del S3 divide una fuente de 80 MHz, así que las únicas opciones son 80 y 40 MHz, sin nada intermedio.

Con los cables como cuello de botella, la opción restante era enviar menos datos. Cada banda de 8 líneas se identifica mediante una huella al convertirse a RGB565, y las bandas idénticas a lo que el panel ya muestra se omiten. Los frames descartados pasaron de un 40% a un 6%. Eso da aproximadamente 55 fps en pantalla cuando la cámara está quieta, mientras que la lógica del juego se mantiene a plena velocidad.

El crash que detectó un watchpoint de hardware

El siguiente informe fue “pulsé START y se reinició”. Antes había habido otro: “apareció texto, luego la pantalla se puso blanca y luego se reinició”.

El pánico estaba en ClearOTag, llamado desde el bucle principal con un puntero de tabla de ordenación de 0x4b8. El bucle principal avanza con g_CurrentBuffer = g_CurrentBuffer->next, así que retrocediendo, algo había escrito 0x44 en el enlace next de uno de los dos búferes de la GPU.

El ESP32-S3 tiene dos watchpoints de hardware. Armé ambos, uno en el campo next de cada búfer, dos segundos después del arranque, mucho después de la única escritura legítima. Pulsar START detuvo la CPU en el store del culpable:

Debug exception reason: Watchpoint 1 triggered
AddPrim ← MenuDrawImg ← MenuDrawChar ← MenuDrawStr ← MenuDrawStats ← MenuDraw

El menú de pausa toma el siguiente sprite libre con &g_CurrentBuffer->sprite[g_GpuUsage.sp] y nunca comprueba el límite. sprite[] es el último campo de un búfer de GPU, y el segundo búfer empieza justo después, comenzando por su enlace next. Dibujar el texto de estadísticas superó los 512 sprites, y AddPrim escribió una cabecera de primitiva sobre el enlace. El mismo juego comprueba g_GpuUsage.sp < MAX_SPRT_COUNT en otros sitios, pero el menú no lo hacía. Dos líneas lo arreglaron.

Los dos búferes de GPU están uno tras otro en memoria; el sprite 513 del menú de pausa aterriza en el enlace next del siguiente búfer, algo que un watchpoint de hardware detectó

El bus SPI compartido

Justo después, apareció un crash distinto al cargar escenarios: una aserción en el driver SPI, running_cmd == 0. La tarjeta iniciaba un comando mientras una transferencia DMA del panel seguía en el cable. En la placa Waveshare la tarjeta solo transmitía música opcional; en la consola portátil lo lleva todo.

La solución es un mutex:

Instrumentar sin engañarse a uno mismo

Dos lecciones de este tramo:

Ejecutar cada compilación sin flashear la placa

Después de varias horas de prueba y error, flashear la placa tras cada cambio era la parte más lenta del ciclo: escribir la imagen por USB, esperar el reinicio, reconectar el puerto serie sin reiniciar la placa otra vez, leer el log, repetir. Así que para ejecutar y comprobar cada compilación usé velxio-cli, el cliente de línea de comandos de Velxio. Toma el mismo .bin que producía mi toolchain, lo ejecuta en el ESP32-S3 simulado de Velxio durante una cantidad fija de tiempo simulado y devuelve la salida serie, así que podía leer contadores y tiempos sin tocar el hardware. La placa solo recibía una nueva imagen cuando una compilación lo merecía.

# velxio.toml, next to the build: board = "xiao-esp32-s3", firmware = the merged .bin
velxio-cli run --timeout 5000 --timeout-exit-code 0 --serial-log-file serial.log .

Instalarlo y hacer una primera ejecución lleva unos minutos: consulta el inicio rápido de velxio-cli.

Añadir un escenario: el presupuesto de memoria, otra vez

Salir del Alchemy Lab lleva a la Castle Entrance (NP3). Añadirla desbordó la RAM interna en 160 KB. Los gráficos comprimidos del escenario, las paletas, los mapas de tiles y las tablas de sprites estaban declarados como escribibles, así que se copiaban a RAM en el arranque. Marcarlos como const los movió a flash. El port original para PC ya había añadido NP3, y ese commit se aplicó sin problemas.

Dos descubrimientos más:

Los archivos generados se regeneran a partir del disco de cada usuario, así que los cambios de const viven en un script (esp32/const_stage_tables.py) en lugar de en las fuentes generadas.

Consejos para un port similar

Estado y próximos pasos

Otro combate en el Laboratorio de Alquimia, en la consola portátil

El código está en GitHub en davidmonterocrespo24/sotn-decomp (rama esp32-port). El diagrama de conexiones y la lista de componentes están en la guía de hardware. Necesitas tu propia copia del juego; el repositorio no contiene datos del juego.

Gracias a los colaboradores de la descompilación de SOTN, y a Xeeynamo por psyz. Este proyecto está construido sobre su trabajo.


Compartir en:
David Montero

Escrito por

David Montero

Creator of Velxio, the open-source circuit and Arduino simulator.

GitHub velxio.dev

Artículos relacionados


Artículo siguiente
Metal Gear Solid ejecutándose de forma nativa en el ESP32-S3