
No artigo sobre Metal Gear Solid mostrei o console portátil que construí em torno de um XIAO ESP32S3 Sense. Castlevania: Symphony of the Night (SOTN) é o outro jogo de PlayStation que ele roda, e o que veio primeiro: estava rodando a 60 fps em uma placa de desenvolvimento Waveshare ESP32-S3 antes de o portátil existir, e depois migrou para ele.
Foi possível pelo mesmo motivo que Metal Gear Solid. A descompilação da comunidade de SOTN compila como C portável, então o jogo pode ser compilado para o ESP32-S3 em vez de emulado.
Gameplay no portátil: assista no YouTube.
Este artigo aborda como o port está estruturado, como o jogo se encaixa na memória do SoC, como o rasterizador por software alcançou 60 fps, os bugs que o hardware original vinha escondendo e um console portátil que construí em torno de um Seeed XIAO ESP32S3 Sense.
Por que um port nativo é possível
Emular um PlayStation em um ESP32-S3 está fora de alcance. Um emulador precisa interpretar uma CPU MIPS e uma GPU instrução por instrução, o que exige várias vezes o desempenho que o SoC possui.
Fazer um port é diferente. O projeto de descompilação de SOTN reconstruiu o jogo como código-fonte C que compila para um executável de PC. A lógica do jogo é C comum, e a única coisa que o prende ao console é o SDK da Sony: as chamadas de biblioteca que desenham polígonos, carregam texturas, leem o controle e reproduzem sons. A build de PC substitui esse SDK por psyz, uma reimplementação sobre SDL.
O trabalho, então, tem três partes:
- Compilar o jogo para Xtensa.
- Substituir o backend de PC do psyz por um para o SoC.
- Fazer tudo caber.
O SoC alvo é o ESP32-S3:
- Dois núcleos Xtensa LX7 a 240 MHz.
- 512 KB de SRAM interna.
- 8 MB de PSRAM octal (externa, muito mais lenta).
- 16 MB de flash na primeira placa e 8 MB no portátil.
- Sem GPU.
Arquitetura
As camadas, do jogo até o hardware:
- Jogo: engine descompilada, código do jogador e das armas, estágios
- psyz: reimplementação do SDK com PSYZ_RENDERER=soft
- Camada de plataforma (esp32/main): VSync, root counters, entrada, arquivos FAT, áudio, scanout do LCD
- Hardware do ESP32-S3: núcleos LX7, SRAM, PSRAM, flash, LCD SPI
- Jogo: a engine descompilada (
src/dra), o código do jogador e das armas, e cada área do castelo (“estágio”). Estes permanecem inalterados, exceto pelas correções de bugs. - psyz: a reimplementação do SDK. Adicionei a ele um terceiro renderizador,
PSYZ_RENDERER=soft. É um parser de pacotes mais um núcleo de rasterização sem dependência de SDL, então o mesmo arquivo compila no Windows (para testes contra a referência de GPU) e no SoC. - Camada de plataforma (
esp32/main): cadência de VSync a 59,94 Hz, os root counters que comandam o sequenciador de som, entrada do controle, acesso a arquivos via FAT, mixagem de áudio no segundo núcleo e o scanout do LCD.
Duas decisões de design moldaram todo o resto.
A VRAM continua sendo o próprio buffer do jogo. O PlayStation tem 1 MB de memória de vídeo, 1024×512 pixels em cores de 16 bits. A descompilação já a modela como um array simples. O rasterizador desenha diretamente nela (na PSRAM), e o LCD é alimentado a partir dela. Não há cópias sombra nem conversões de formato, exceto a final para o RGB565 do painel.
Os estágios são linkados estaticamente. Em um PC, cada área do castelo é uma DLL carregada sob demanda. O ESP32-S3 não tem linker dinâmico, então cada área é compilada no firmware, e uma pequena tabela mapeia o nome de um estágio para sua função de inicialização. Essa decisão causou problemas duas vezes, como descrito em “Bugs que o PlayStation perdoa” e “Adicionando um estágio” abaixo.
Encaixando um jogo de PlayStation em 512 KB

O primeiro link falhou por 3,34 MB de RAM interna. A maior parte disso não era culpa do jogo:
g_TileDefDataPoolestava declarado comoTileDefinition[0x40][4][0x1000]. Com ponteiros de 32 bits, isso dá de 16 a 32 MB para armazenar cerca de 1 MB de dados. Retipá-lo para bytes resolveu o maior item.- A biblioteca de som alocava um array de 512 KB na pilha. Isso funciona em um PC; em um microcontrolador, trava imediatamente.
- Buffers grandes que só são tocados ocasionalmente migraram para a PSRAM com
EXT_RAM_BSS_ATTR. - Tabelas somente leitura foram marcadas como
const, o que as coloca na flash.
Esse último item tem uma armadilha: alguns dados “somente leitura” são escritos. O SDK atualiza os cabeçalhos dos bancos de som no lugar quando um banco é aberto. Declarados como const, eles ficam na flash, e escrever na flash através do cache é uma falha grave (“Dbus write to cache”). O padrão que funciona é manter um master const na flash, copiá-lo para um buffer na PSRAM na inicialização e deixar o jogo escrever na cópia.
Após 28 rodadas de link, os números eram:
- 207 KB de dados inicializados na RAM interna.
- 43 KB de dados zerados na RAM interna.
- 55 KB de código em IRAM.
- 3 MB de estáticos na PSRAM.
- Cerca de 100 KB de heap interno livre em tempo de execução.
O rasterizador por software, e como chegar a 60 fps

SOTN é um jogo 2D, e seu mix de primitivas é pequeno: quads texturizados e Gouraud, sprites 16×16 para as camadas de tiles, retângulos e linhas. Não há 3D nem engine de transformação de geometria, o que tornou um rasterizador por software realista.
O rasterizador é bit-exato em relação à build de referência de GPU. Comparei checksums de frame entre a build de PC e a placa após cada otimização.
A maior parte do tempo de frame ia para acesso à memória. Cada pixel texturizado lê um texel e uma entrada de paleta da PSRAM e escreve um pixel na PSRAM. As otimizações que importaram todas reduzem esse tráfego:
| Etapa | Resultado |
|---|---|
| Avanço de arestas de triângulo: divisões, depois acumuladores de 64 bits, depois 16.16 de 32 bits | 64 bits foi mais lento em um núcleo de 32 bits; 16.16 foi o que ficou |
| Scanout movido para o núcleo 1, alimentado por um notify | O jogo nunca espera pelo SPI |
| Cache de paleta na RAM interna, loops de rasterização na IRAM | Warp Room a 31 fps |
| Caminho rápido da camada de tiles: 4 texels por leitura, pares de pixels como stores de 32 bits | 10,4 ms para 6,1 ms por frame |
| Scanout a partir do buffer que o jogo acabou de terminar de desenhar | Sem cópia, um frame a menos de latência |
| PSRAM e flash de 80 para 120 MHz | 19,4 ms para 16,4 ms por frame: 60 fps |
Também tentei um cache de linhas de textura no rasterizador de triângulos e reverti. Não ganhou nada, porque o GCC já tinha içado os loads.
A mudança para fazer o scanout a partir do buffer finalizado precisa de alguma explicação. A fonte óbvia para o display é a área de exibição atual do SDK, mas ela fica um frame atrás: no VSync, ela nomeia o buffer no qual o jogo está prestes a desenhar. Fazer o scan a partir dela significava que o DMA e o rasterizador brigavam pela mesma metade da VRAM durante todo o frame. Ler a origem do display a partir da própria struct de buffer do jogo faz os dois trabalharem em metades opostas por construção.
Bugs que o PlayStation perdoa
O PlayStation não tem proteção de memória. Uma leitura perdida retorna o que estiver lá, e uma escrita perdida cai em memória que muitas vezes ninguém verifica. A build para PC também esconde esses bugs em grande parte, porque suas variáveis estáticas são grandes e tolerantes. O ESP32-S3 tem uma MMU, memória apertada e vizinhos que importam, então fazer o port para ele acabou sendo uma ótima maneira de encontrar bugs latentes.

Como eu os encontrei
Um backtrace de panic nomeia a vítima, não o culpado. A ferramenta que funcionou foi o OpenOCD sobre o USB-JTAG embutido do SoC, junto com o GDB:
- Resetar e parar o SoC através do OpenOCD.
- Definir um breakpoint no handler de panic.
- Quando ele dispara, decodificar o PC real a partir do frame de panic.
- Sempre decodificar contra o ELF exato que foi gravado, porque os endereços mudam a cada rebuild.
Animação de paleta fora dos limites
O Laboratório de Alquimia solicita a animação de paleta (tileset & 0xFF) + 0x7FFF | 0x4000, que indexa a entrada 2 da tabela de paletas do estágio. A tabela tem uma entrada. A leitura fora dos limites caiu na tabela adjacente de bancos de sprites, que então foi registrada como um descritor de animação de paleta e escrita a cada frame.
A correção rejeita descritores cuja extensão excede o buffer de paletas. O mesmo comportamento indefinido existe no upstream; o PC o absorve em um BSS grande.
Dois estágios, um único HitDetection
Com os estágios linkados estaticamente, 122 símbolos globais foram definidos em mais de um estágio. Headers compartilhados implementam coisas como colisão e atualizações de entidades, e no PlayStation apenas um estágio era carregado por vez. Arquivos estáticos deixam o linker resolver silenciosamente cada nome para uma definição. O Laboratório de Alquimia executou o HitDetection da Sala de Teleporte contra suas próprias tabelas de entidades, e a corrupção apareceu uma camada depois.
A correção é uma lista gerada de todos os símbolos que dois estágios compartilham, renomeados por estágio com defines -D.
Salas que modificam seu próprio mapa
Portas e paredes destrutíveis escrevem no tile map da sala. Com os mapas gerados na flash, a primeira porta travou o jogo. A correção é um único ponto onde a camada de primeiro plano da sala ativa é copiada para um buffer gravável na PSRAM.
O nome do inimigo

Com a relíquia Faerie Scroll, o jogo imprime o nome do inimigo que você acerta. BottomCornerText varre a string procurando o terminador FF 00 do PlayStation. As strings do PC são strings C comuns, então a varredura passou do fim e sobrescreveu um buffer de pilha de 64 bytes. O sintoma era “ele congela assim que eu toco em um inimigo.” A correção limita a varredura.
Uma race condition que não existia no PlayStation
O áudio roda no segundo núcleo, e o pull de áudio estava avançando os contadores raiz. Esses contadores disparam o handler de VSync do jogo, uploads de textura e callbacks da fila da GPU. No PlayStation esses são interrupts na mesma CPU. Aqui eles rodavam concorrentemente com o jogo no outro núcleo.
A correção acumula os ticks dos contadores no núcleo 1 e dispara os handlers na thread do jogo.
Input que nunca chegava
O relato era “não consigo pular.” Três bugs estavam empilhados:
- A porta serial USB descarta bytes recebidos enquanto o DTR está baixo, e meu script de teclado o mantinha baixo para evitar resetar a placa.
- O console estava configurado para a UART enquanto o cabo estava no USB nativo.
- Um pino de botão não conectado lia como permanentemente pressionado, o que mantinha o CROSS pressionado para sempre.
O portátil: bring-up de hardware em um XIAO ESP32S3 Sense



O objetivo era um console portátil. É o mesmo hardware que depois rodou Metal Gear Solid: SOTN foi o primeiro jogo nele. As peças:
- Seeed XIAO ESP32S3 Sense: o mesmo SoC ESP32-S3 com 8 MB de PSRAM, 8 MB de flash e um slot de microSD na sua placa de expansão.
- Painel SPI ILI9341 320×240: a resolução nativa do PlayStation, então nenhum escalonamento é necessário.
- Um stick analógico retirado de um controle de drone.
- Oito botões.
O XIAO tem onze pinos utilizáveis. O painel precisa de cinco (SCK, MOSI, MISO, CS, DC) mais uma linha de reset, e o stick precisa de dois pinos ADC. Isso deixa três para oito botões.
Seis dos botões compartilham um pino ADC através de uma escada de resistores: um pull-up de 10k e, por botão, um resistor para o terra (0 Ω, 2k2, 4k7, 10k, 22k, 47k). Cada botão produz uma tensão diferente. A limitação é que dois botões pressionados juntos leem como o mais baixo. O par que cada jogo mantém pressionado junto (ataque e pulo em SOTN) recebe os dois pinos dedicados restantes.
O diagrama de fiação completo, a lista de peças e o mapa de botões estão no guia de hardware no repositório.
O bring-up foi sua própria lista de lições. Antes de tocar no jogo, escrevi um firmware de teste separado que desenha barras de cor e mostra cada botão e o stick na tela:
- O painel estava branco, e ficava mais escuro quando eu pressionava botões. Ele não tinha fio de terra, então estava se alimentando através dos diodos de proteção nos seus pinos de dados. Adicionar o GND resolveu instantaneamente.
- O painel era um ILI9341 em vez do ILI9488 que eu tinha planejado. É um controlador diferente com um formato de pixel diferente (RGB565 sobre SPI em vez de RGB666). Seu driver não é embutido no ESP-IDF e vem do registro de componentes.
- Uma linha de reset de verdade importa. Com o RESET preso em nível alto e apenas um reset por software, o painel não iniciava de forma confiável.
- Botões de quatro pernas: duas pernas do mesmo lado já estão conectadas internamente. Se você ligar essas, o botão fica sempre pressionado. Use as pernas diagonais.
- O stick derivava sozinho. Um gimbal de drone repousa onde suas molas o colocam (2317 de 4095 no meu, quando você poderia assumir 2048), e o ADC é ruidoso. A correção:
- Calibrar o centro na inicialização.
- Aprender o curso de cada eixo conforme ele é usado.
- Média de 8 amostras.
- Aplicar uma zona morta de 60 contagens, quatro vezes o ruído medido em repouso.
- O eixo Y estava invertido duas vezes. O firmware de teste negava o Y para que “cima move o ponto para cima” em uma tela cujo Y cresce para baixo. O jogo recebe bits de direção, onde cima é apenas cima. Carregar a convenção da tela através disso o inverteu novamente.
O portátil: display, entrada e um crash pego por um watchpoint
Tudo no cartão SD
8 MB de flash não conseguem guardar um app de 3 MB ao lado de 3,7 MB de dados do jogo, então no portátil todos os dados vêm do cartão microSD. Esse cartão compartilha o barramento SPI com o painel, o que mais tarde causou um crash próprio (veja “O barramento SPI compartilhado” abaixo).
Duas armadilhas específicas desta placa
O GPIO43 é tanto o pino de dados/comando do painel quanto o TX da UART0. O código da Waveshare instalou o driver da UART para seu console serial. Fazer isso silenciosamente devolve o pino à UART, e o painel para de distinguir comandos de pixels. O portátil usa USB nativo apenas para seu console.
printf via USB nativo bloqueia quando ninguém está lendo. Com um PC conectado você nunca percebe. Desconectado, o buffer de transmissão enche em poucos segundos e o jogo para dentro de um printf, em um dispositivo feito para rodar desconectado. Toda a saída, incluindo o próprio logging do ESP-IDF, agora passa por um wrapper que escreve com timeout zero e descarta o que ninguém lê.
Flicker, depois retângulos deslizantes
A primeira build piscava. Assumi que o clock SPI estava lento demais e o aumentei de 40 para 80 MHz. Isso ajudou um pouco, mas instrumentar o scanout mostrou o que realmente estava acontecendo:
- Um frame levava cerca de 24 ms para ser enviado.
- O jogo produzia um a cada 16,7 ms.
- Então o jogo ultrapassava o painel: ele trocava os buffers e começava a redesenhar aquele que a DMA ainda estava transmitindo.
Isso era um problema de coerência de buffer. A correção foi o caminho de cópia que o port da Waveshare tinha mantido como rede de segurança: copiar o frame finalizado, depois enviar a cópia. Minha primeira versão disso tinha dois bugs:
- Ela copiava apenas as linhas do retângulo de recorte atual do rasterizador, que um efeito pode encolher para poucas linhas. A tela congelava enquanto o jogo continuava rodando.
- Com dois buffers de cópia, ela podia sobrescrever aquele que ainda estava sendo enviado. Em uma cena com rolagem horizontal isso aparece como retângulos deslizando lateralmente.
Mesmo depois das duas correções os retângulos permaneceram. Baixar o clock SPI de volta para 40 MHz fez com que sumissem: os fios jumper não aguentavam 80 MHz, e um deslize de byte desloca uma faixa inteira de 8 linhas. O clock SPI do S3 divide uma fonte de 80 MHz, então as únicas opções são 80 e 40 MHz, sem nada no meio.
Com os fios sendo o gargalo, a opção restante era enviar menos dados. Cada faixa de 8 linhas recebe uma impressão digital conforme é convertida para RGB565, e faixas idênticas ao que o painel já mostra são puladas. Os frames descartados caíram de cerca de 40% para 6%. Isso dá aproximadamente 55 fps na tela quando a câmera está parada, enquanto a lógica do jogo permanece em velocidade máxima.
O crash que um watchpoint de hardware pegou
O próximo relato foi “apertei START e ele reiniciou.” Antes disso houve outro: “apareceu texto, depois a tela ficou branca, depois reiniciou.”
O panic estava em ClearOTag, chamado do loop principal com um ponteiro de ordering table de 0x4b8. O loop principal avança com g_CurrentBuffer = g_CurrentBuffer->next, então, trabalhando de trás para frente, algo tinha escrito 0x44 no link next de um dos dois buffers da GPU.
O ESP32-S3 tem dois watchpoints de hardware. Armei ambos, um em cada campo next de cada buffer, dois segundos após o boot, bem depois da única escrita legítima. Apertar START parou a CPU no store do culpado:
Debug exception reason: Watchpoint 1 triggered
AddPrim ← MenuDrawImg ← MenuDrawChar ← MenuDrawStr ← MenuDrawStats ← MenuDraw
O menu de pausa pega o próximo sprite livre com &g_CurrentBuffer->sprite[g_GpuUsage.sp] e nunca verifica o limite. sprite[] é o último campo de um buffer da GPU, e o segundo buffer começa logo depois dele, começando com seu link next. Desenhar o texto de estatísticas ultrapassou 512 sprites, e AddPrim escreveu um cabeçalho de primitiva sobre o link. O mesmo jogo verifica g_GpuUsage.sp < MAX_SPRT_COUNT em outros lugares, mas o menu não. Duas linhas resolveram.
O barramento SPI compartilhado
Logo depois disso, um crash diferente apareceu em carregamentos de fase: uma assertion no driver SPI, running_cmd == 0. O cartão iniciou um comando enquanto uma transferência DMA do painel ainda estava no fio. Na placa Waveshare o cartão só transmitia música opcional; no portátil ele carrega tudo.
A correção é um mutex:
- O painel o mantém por um frame e drena sua DMA em andamento antes de liberá-lo.
- O cartão o pega em torno de cada comando, envolvendo o hook
do_transactiondo driver.
Instrumentar sem se enganar
Duas lições deste trecho:
- Meu listener serial estava reiniciando a placa. Abrir a porta da forma padrão aciona DTR/RTS, que no USB nativo do S3 são reset e boot-select. Toda vez que eu conectava para inspecionar uma tela congelada, eu reiniciava a placa e lia um log saudável. A correção é construir o objeto da porta, baixar ambas as linhas e então abri-la.
- Meça a operação inteira. Minha primeira medição de scanout separava “espera” e “conversão” mas deixava de fora a própria chamada de desenho, que bloqueia quando a fila SPI está cheia. Ela reportava 5 ms para um frame que levava 24 ms.
Rodar cada build sem gravar na placa
Depois de várias horas de tentativa e erro, gravar na placa após cada mudança era a parte mais lenta do ciclo: escrever a imagem via USB, esperar o reboot, reconectar a porta serial sem reiniciar a placa de novo, ler o log, repetir. Então, para rodar e verificar cada build, usei o velxio-cli, o cliente de linha de comando do Velxio. Ele pega o mesmo .bin que minha toolchain produziu, roda no ESP32-S3 simulado do Velxio por uma quantidade fixa de tempo simulado e devolve a saída serial, então eu podia ler contadores e timings sem tocar no hardware. A placa só recebia uma nova imagem quando uma build valia a pena.
# 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 .
Instalá-lo e fazer uma primeira execução leva alguns minutos: veja o quickstart do velxio-cli.
Adicionando uma fase: o orçamento de memória, de novo
Sair do Alchemy Lab leva à Castle Entrance (NP3). Adicioná-la estourou a RAM interna em 160 KB. Os gráficos comprimidos, paletas, tile maps e tabelas de sprites da fase estavam declarados como graváveis, então eram copiados para a RAM no boot. Marcá-los como const os moveu para a flash. O port original para PC já tinha adicionado NP3, e aquele commit passou sem problemas.
Mais duas descobertas:
- Os sprites do Richter (o personagem do prólogo) eram 85 KB de dados graváveis na RAM, nunca usados no jogo normal. Marcá-los como
constnovamente significou que, após adicionar NP3, o uso de RAM interna estava menor do que antes. - O linker reportou 16 símbolos duplicados entre NP3 e as outras fases. Havia 31. Comparar as tabelas de símbolos com
nmencontrou os outros 15, incluindoEntitySlograeEntityGaibon: código de chefe que NP3 teria pegado silenciosamente do Alchemy Lab.
Os arquivos gerados são regenerados a partir do disco de cada usuário, então as mudanças de const vivem em um script (esp32/const_stage_tables.py) em vez de nos fontes gerados.
Conselhos para um port semelhante
- Escolha uma descompilação que já compile um alvo portável. É isso que torna um port nativo possível onde um emulador não é.
- Faça a placa te dizer o que está errado. Use contadores, timings divididos em suas partes e checksums de frame. A maioria das minhas hipóteses erradas foi refutada por um número.
- Um backtrace nomeia a vítima. Para corrupção de memória, um watchpoint de hardware no endereço corrompido nomeia o culpado.
- Memória é o orçamento de desempenho. Neste SoC, menos acessos à PSRAM vencem aritmética esperta todas as vezes.
- A tolerância do hardware original esconde bugs. Espere encontrar alguns.
Status e próximos passos

- Placa Waveshare: Laboratório de Alquimia a 60 fps.
- Portátil: lógica do jogo em velocidade máxima, tela em torno de 55 fps quando a câmera está parada.
- Estágios linkados: Laboratório de Alquimia, Sala de Teleporte e o menu de título/save. A Entrada do Castelo está linkada e aguardando seu primeiro teste no portátil.
- Som: efeitos funcionam, e a música XA toca quando a imagem do disco está no cartão.
- Próximo: mais estágios (o orçamento de memória agora tem espaço), e um chicote soldado para o portátil tentar SPI de 80 MHz novamente.
O código está no GitHub em davidmonterocrespo24/sotn-decomp (branch esp32-port). O diagrama de ligação e a lista de peças estão no guia de hardware. Você precisa da sua própria cópia do jogo; o repositório não contém dados do jogo.
Agradecimentos aos contribuidores da descompilação de SOTN, e a Xeeynamo pelo psyz. Este projeto é construído sobre o trabalho deles.