
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:
- Compilar el juego para Xtensa.
- Sustituir el backend de PC de psyz por uno para el SoC.
- Hacer que todo encaje.
El SoC objetivo es el ESP32-S3:
- Dos núcleos Xtensa LX7 a 240 MHz.
- 512 KB de SRAM interna.
- 8 MB de PSRAM octal (externa, mucho más lenta).
- 16 MB de flash en la primera placa y 8 MB en la portátil.
- Sin GPU.
Arquitectura
Las capas, desde el juego hasta el hardware:
- Juego: motor descompilado, código del jugador y las armas, escenarios
- psyz: reimplementación del SDK con PSYZ_RENDERER=soft
- Capa de plataforma (esp32/main): VSync, contadores raíz, entrada, archivos FAT, audio, salida al LCD
- Hardware del ESP32-S3: núcleos LX7, SRAM, PSRAM, flash, LCD SPI
- Juego: el motor descompilado (
src/dra), el código del jugador y las armas, y cada zona del castillo (“escenario”). Estos no cambian salvo por las correcciones de errores. - psyz: la reimplementación del SDK. Le añadí un tercer renderizador,
PSYZ_RENDERER=soft. Es un analizador de paquetes más un núcleo de rasterización sin dependencia de SDL, así que el mismo archivo compila en Windows (para probar contra la referencia de GPU) y en el SoC. - Capa de plataforma (
esp32/main): sincronización de VSync a 59,94 Hz, los contadores raíz que gobiernan el secuenciador de sonido, la entrada del mando, el acceso a archivos sobre FAT, la mezcla de audio en el segundo núcleo y la salida al LCD.
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

El primer enlazado falló por 3,34 MB de RAM interna. La mayor parte de eso no era culpa del juego:
g_TileDefDataPoolestaba declarado comoTileDefinition[0x40][4][0x1000]. Con punteros de 32 bits eso son de 16 a 32 MB para contener alrededor de 1 MB de datos. Reconvertirlo a bytes arregló el elemento más grande.- La biblioteca de sonido reservaba un array de 512 KB en la pila. Eso funciona en un PC; en un microcontrolador se cuelga de inmediato.
- Los búferes grandes que solo se tocan de vez en cuando se movieron a PSRAM con
EXT_RAM_BSS_ATTR. - Las tablas de solo lectura se marcaron como
const, lo que las coloca en flash.
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:
- 207 KB de datos inicializados en RAM interna.
- 43 KB de datos inicializados a cero en RAM interna.
- 55 KB de código en IRAM.
- 3 MB de estáticos en PSRAM.
- Alrededor de 100 KB de heap interno libre en tiempo de ejecución.
El rasterizador por software, y llegar a 60 fps

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:
| Paso | Resultado |
|---|---|
| Avance de aristas de triángulos: divisiones, luego acumuladores de 64 bits, luego 32 bits 16.16 | Los 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ón | El juego nunca espera al SPI |
| Caché de paleta en RAM interna, bucles de rasterización en IRAM | Warp 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 bits | De 10,4 ms a 6,1 ms por fotograma |
| Salida desde el búfer que el juego acaba de terminar de dibujar | Sin copia, un fotograma menos de latencia |
| PSRAM y flash de 80 a 120 MHz | De 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.

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:
- Reiniciar y detener el SoC mediante OpenOCD.
- Establecer un punto de interrupción en el manejador de pánico.
- Cuando se activa, decodificar el PC real desde el marco de pánico.
- 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

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:
- El puerto serie USB descarta los bytes entrantes mientras DTR está bajo, y mi script de teclado lo mantenía bajo para evitar reiniciar la placa.
- La consola estaba configurada para el UART mientras el cable estaba en el USB nativo.
- Un pin de botón sin cablear se leía como permanentemente pulsado, lo que mantenía CROSS presionado para siempre.
La consola portátil: puesta en marcha del hardware en un XIAO ESP32S3 Sense



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:
- Seeed XIAO ESP32S3 Sense: el mismo SoC ESP32-S3 con 8 MB de PSRAM, 8 MB de flash y una ranura microSD en su placa de expansión.
- Panel SPI ILI9341 de 320×240: la resolución nativa de la PlayStation, así que no se necesita escalado.
- Un stick analógico sacado de un mando de dron.
- Ocho botones.
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.
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:
- El panel estaba en blanco y se atenuaba cuando pulsaba botones. No tenía cable de tierra, así que se alimentaba a través de los diodos de protección de sus pines de datos. Añadir GND lo arregló al instante.
- El panel era un ILI9341 en lugar del ILI9488 que había planeado. Es un controlador diferente con un formato de píxel diferente (RGB565 sobre SPI en lugar de RGB666). Su driver no viene integrado en ESP-IDF y proviene del registro de componentes.
- Una línea de reset real importa. Con RESET fijado en alto y solo un reset por software, el panel no arrancaba de forma fiable.
- Botones de cuatro patas: dos patas del mismo lado ya están conectadas internamente. Si cableas esas, el botón está siempre pulsado. Usa las patas diagonales.
- El stick se desviaba solo. Un gimbal de dron descansa donde lo pongan sus resortes (2317 de 4095 en el mío, donde podrías asumir 2048), y el ADC tiene ruido. La corrección:
- Calibrar el centro al arrancar.
- Aprender el recorrido de cada eje a medida que se usa.
- Promediar 8 muestras.
- Aplicar una zona muerta de 60 cuentas, cuatro veces el ruido medido en reposo.
- El eje Y estaba invertido dos veces. El firmware de pruebas negaba Y para que “arriba mueva el punto hacia arriba” en una pantalla cuyo Y crece hacia abajo. El juego toma bits de dirección, donde arriba es simplemente arriba. Trasladar la convención de la pantalla lo invirtió de nuevo.
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:
- Un frame tardaba unos 24 ms en enviarse.
- El juego producía uno cada 16,7 ms.
- Así que el juego adelantaba al panel: intercambiaba los búferes y empezaba a redibujar el que la DMA todavía estaba transmitiendo.
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:
- Solo copiaba las filas del rectángulo de recorte actual del rasterizador, que un efecto puede reducir a unas pocas filas. La pantalla se congelaba mientras el juego seguía ejecutándose.
- Con dos búferes de copia, podía sobrescribir el que todavía se estaba enviando. En una escena con scroll horizontal eso se manifiesta como rectángulos que se deslizan lateralmente.
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.
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:
- El panel lo retiene durante un frame y drena su DMA en vuelo antes de liberarlo.
- La tarjeta lo toma alrededor de cada comando, envolviendo el hook
do_transactiondel driver.
Instrumentar sin engañarse a uno mismo
Dos lecciones de este tramo:
- Mi listener serie estaba reiniciando la placa. Abrir el puerto de la forma predeterminada activa DTR/RTS, que en el USB nativo del S3 son reset y boot-select. Cada vez que me conectaba para inspeccionar una pantalla congelada, reiniciaba la placa y leía un log saludable. La solución es construir el objeto del puerto, bajar ambas líneas y luego abrirlo.
- Medir la operación completa. Mi primera medición del scanout separaba “espera” y “conversión” pero dejaba fuera la propia llamada de dibujo, que se bloquea cuando la cola SPI está llena. Reportaba 5 ms para un frame que tardaba 24 ms.
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 sprites de Richter (el personaje del prólogo) eran 85 KB de datos escribibles en RAM, nunca usados en el juego normal. Marcarlos como
constde nuevo hizo que, tras añadir NP3, el uso de RAM interna fuera menor que antes. - El enlazador reportó 16 símbolos duplicados entre NP3 y los demás escenarios. Había 31. Comparar las tablas de símbolos con
nmencontró los otros 15, incluidosEntitySlograyEntityGaibon: código de jefes que NP3 habría tomado prestado silenciosamente del Alchemy Lab.
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
- Elige una descompilación que ya compile un objetivo portable. Eso es lo que hace posible un port nativo donde un emulador no lo es.
- Haz que la placa te diga qué va mal. Usa contadores, tiempos desglosados en sus partes y sumas de comprobación de frames. La mayoría de mis hipótesis erróneas fueron refutadas por un número.
- Un backtrace nombra a la víctima. Para corrupción de memoria, un watchpoint de hardware en la dirección corrompida nombra al culpable.
- La memoria es el presupuesto de rendimiento. En este SoC, menos accesos a PSRAM superan a la aritmética ingeniosa siempre.
- La tolerancia del hardware original oculta errores. Espera encontrar algunos.
Estado y próximos pasos

- Placa Waveshare: Laboratorio de Alquimia a 60 fps.
- Consola portátil: lógica del juego a plena velocidad, pantalla a unos 55 fps cuando la cámara está quieta.
- Escenarios enlazados: Laboratorio de Alquimia, Sala de Teletransporte y el menú de título/guardado. La Entrada del Castillo está enlazada y esperando su primera prueba en la consola portátil.
- Sonido: los efectos funcionan, y la música XA se reproduce cuando la imagen del disco está en la tarjeta.
- Siguiente: más escenarios (el presupuesto de memoria ahora tiene margen), y un arnés soldado para la consola portátil para volver a probar SPI a 80 MHz.
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.