Voltar

Castlevania: Symphony of the Night rodando nativamente no ESP32-S3

O portátil XIAO ESP32S3 rodando Castlevania: Symphony of the Night no Laboratório de Alquimia

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:

  1. Compilar o jogo para Xtensa.
  2. Substituir o backend de PC do psyz por um para o SoC.
  3. Fazer tudo caber.

O SoC alvo é o ESP32-S3:

Arquitetura

As camadas, do jogo até o hardware:

  1. Jogo: engine descompilada, código do jogador e das armas, estágios
  2. psyz: reimplementação do SDK com PSYZ_RENDERER=soft
  3. Camada de plataforma (esp32/main): VSync, root counters, entrada, arquivos FAT, áudio, scanout do LCD
  4. Hardware do ESP32-S3: núcleos LX7, SRAM, PSRAM, flash, LCD SPI

As camadas do port e os dois núcleos: o jogo e o rasterizador no núcleo 0, scanout do LCD e áudio no núcleo 1, e o painel e o microSD compartilhando o SPI2 no portátil

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

Alucard ao lado de uma das estátuas do laboratório

O primeiro link falhou por 3,34 MB de RAM interna. A maior parte disso não era culpa do jogo:

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:

O rasterizador por software, e como chegar a 60 fps

As chamas do laboratório: efeitos semitransparentes desenhados pelo rasterizador por software

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:

EtapaResultado
Avanço de arestas de triângulo: divisões, depois acumuladores de 64 bits, depois 16.16 de 32 bits64 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 notifyO jogo nunca espera pelo SPI
Cache de paleta na RAM interna, loops de rasterização na IRAMWarp Room a 31 fps
Caminho rápido da camada de tiles: 4 texels por leitura, pares de pixels como stores de 32 bits10,4 ms para 6,1 ms por frame
Scanout a partir do buffer que o jogo acabou de terminar de desenharSem cópia, um frame a menos de latência
PSRAM e flash de 80 para 120 MHz19,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.

Lutando no Laboratório de Alquimia no portátil

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:

  1. Resetar e parar o SoC através do OpenOCD.
  2. Definir um breakpoint no handler de panic.
  3. Quando ele dispara, decodificar o PC real a partir do frame de panic.
  4. 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

A caixa de nome do inimigo na parte inferior da tela, desenhada pelo mesmo código BottomCornerText que costumava estourar a pilha

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:

O portátil: bring-up de hardware em um XIAO ESP32S3 Sense

O portátil finalizado visto de cima: painel ILI9341, stick de gimbal de drone, botões e o XIAO na placa de prototipagem

A parte de trás do portátil: fiação ponto a ponto na placa de prototipagem e o próprio slot de SD do painel (não utilizado). O módulo de câmera do XIAO, removido, fica ao lado

A frente do portátil ao lado do módulo de câmera do XIAO, que o port não usa e foi retirado

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:

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.

Fiação do portátil, desenhada com a arte de peças do Velxio: XIAO ESP32S3 Sense, painel ILI9341, stick analógico, escada de resistores de seis botões e dois botões diretos

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 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:

Linha do tempo: o jogo desenha um frame a cada 16,7 ms nos buffers A e B enquanto o painel leva cerca de 24 ms para receber um, então o jogo redesenha um buffer que a DMA ainda está enviando

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:

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.

Os dois buffers da GPU ficam lado a lado na memória; o 513º sprite do menu de pausa cai no link next do próximo buffer, o que um watchpoint de hardware pegou

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:

Instrumentar sem se enganar

Duas lições deste trecho:

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 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

Status e próximos passos

Outra luta no Laboratório de Alquimia, no portátil

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.


Compartilhar em:
David Montero

Escrito por

David Montero

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

GitHub velxio.dev

Artigos relacionados


Próximo artigo
Metal Gear Solid rodando nativamente no ESP32-S3