Indietro

Castlevania: Symphony of the Night in esecuzione nativa sull'ESP32-S3

La console portatile XIAO ESP32S3 che esegue Castlevania: Symphony of the Night nel Laboratorio di Alchimia

Nell’articolo su Metal Gear Solid ho mostrato la console portatile che ho costruito attorno a un XIAO ESP32S3 Sense. Castlevania: Symphony of the Night (SOTN) è l’altro gioco PlayStation che esegue, e il primo ad essere arrivato: girava a 60 fps su una scheda di sviluppo Waveshare ESP32-S3 prima che la console portatile esistesse, e in seguito è stato spostato su di essa.

È stato possibile per lo stesso motivo di Metal Gear Solid. La decompilazione della community di SOTN si compila come C portabile, quindi il gioco può essere compilato per l’ESP32-S3 invece di essere emulato.

Gameplay sulla console portatile: guardalo su YouTube.

Questo articolo copre come è strutturato il port, come il gioco entra nella memoria del SoC, come il rasterizzatore software ha raggiunto i 60 fps, i bug che l’hardware originale aveva nascosto e una console portatile che ho costruito attorno a un Seeed XIAO ESP32S3 Sense.

Perché un port nativo è possibile

Emulare una PlayStation su un ESP32-S3 è fuori portata. Un emulatore deve interpretare una CPU MIPS e una GPU istruzione per istruzione, il che richiede diverse volte le prestazioni che il SoC possiede.

Il porting è diverso. Il progetto di decompilazione di SOTN ha ricostruito il gioco come codice sorgente C che si compila in un eseguibile per PC. La logica di gioco è C ordinario, e l’unica cosa che lo lega alla console è l’SDK di Sony: le chiamate di libreria che disegnano poligoni, caricano texture, leggono il controller e riproducono suoni. La build per PC sostituisce quell’SDK con psyz, una reimplementazione basata su SDL.

Il lavoro, quindi, si divide in tre parti:

  1. Compilare il gioco per Xtensa.
  2. Sostituire il backend PC di psyz con uno per il SoC.
  3. Far entrare tutto.

Il SoC di destinazione è l’ESP32-S3:

Architettura

I livelli, dal gioco fino all’hardware:

  1. Gioco: motore decompilato, codice del giocatore e delle armi, stage
  2. psyz: reimplementazione dell’SDK con PSYZ_RENDERER=soft
  3. Livello piattaforma (esp32/main): VSync, root counter, input, file FAT, audio, scanout LCD
  4. Hardware ESP32-S3: core LX7, SRAM, PSRAM, flash, LCD SPI

I livelli del port e i due core: il gioco e il rasterizzatore sul core 0, scanout LCD e audio sul core 1, e il pannello e la microSD che condividono SPI2 sulla console portatile

Due decisioni progettuali hanno plasmato tutto il resto.

La VRAM resta il buffer del gioco stesso. La PlayStation ha 1 MB di memoria video, 1024×512 pixel a colori a 16 bit. La decomp la modella già come un semplice array. Il rasterizzatore disegna direttamente al suo interno (in PSRAM), e l’LCD viene alimentato da essa. Non ci sono copie shadow né conversioni di formato, tranne quella finale verso l’RGB565 del pannello.

Gli stage sono linkati staticamente. Su PC, ogni area del castello è una DLL caricata su richiesta. L’ESP32-S3 non ha un linker dinamico, quindi ogni area è compilata nel firmware, e una piccola tabella mappa il nome di uno stage alla sua funzione di init. Questa decisione ha causato problemi due volte, come descritto in “Bug che la PlayStation perdona” e “Aggiungere uno stage” qui sotto.

Far entrare un gioco PlayStation in 512 KB

Alucard accanto a una delle statue del laboratorio

Il primo link è fallito per 3,34 MB di RAM interna. La maggior parte di ciò non era colpa del gioco:

Quest’ultima ha una trappola: alcuni dati “di sola lettura” vengono scritti. L’SDK aggiorna gli header dei sound bank sul posto quando un bank viene aperto. Dichiarati const, risiedono in flash, e scrivere in flash attraverso la cache è un hard fault (“Dbus write to cache”). Il pattern che funziona è mantenere un master const in flash, copiarlo in un buffer in PSRAM all’avvio e lasciare che il gioco scriva sulla copia.

Dopo 28 cicli di linking, i numeri erano:

Il rasterizzatore software, e come arrivare a 60 fps

Le fiamme del laboratorio: effetti semi-trasparenti disegnati dal rasterizzatore software

SOTN è un gioco 2D, e il suo mix di primitive è ridotto: quad texture e Gouraud, sprite 16×16 per i layer di tile, rettangoli e linee. Non c’è 3D né motore di trasformazione geometrica, il che ha reso realistico un rasterizzatore software.

Il rasterizzatore è bit-exact rispetto alla build di riferimento GPU. Ho confrontato i checksum dei frame tra la build PC e la scheda dopo ogni ottimizzazione.

La maggior parte del tempo per frame era dovuta agli accessi in memoria. Ogni pixel texturizzato legge un texel e una voce di palette dalla PSRAM e scrive un pixel in PSRAM. Le ottimizzazioni che hanno contato riducono tutte quel traffico:

PassoRisultato
Edge stepping dei triangoli: divisioni, poi accumulatori a 64 bit, poi 32 bit 16.16Il 64 bit era più lento su un core a 32 bit; il 16.16 è stato quello finale
Scanout spostato sul core 1, alimentato da una notifyIl gioco non attende mai l’SPI
Cache della palette in RAM interna, cicli di rasterizzazione in IRAMWarp Room a 31 fps
Fast path del layer di tile: 4 texel per lettura, coppie di pixel come store a 32 bitDa 10,4 ms a 6,1 ms per frame
Scanout dal buffer che il gioco ha appena finito di disegnareNessuna copia, un frame in meno di latenza
PSRAM e flash da 80 a 120 MHzDa 19,4 ms a 16,4 ms per frame: 60 fps

Ho anche provato una cache delle righe di texture nel rasterizzatore dei triangoli e l’ho rimossa. Non ha guadagnato nulla, perché GCC aveva già sollevato i load.

Il cambiamento per fare lo scanout dal buffer finito necessita di qualche spiegazione. La fonte ovvia per il display è l’area di visualizzazione corrente dell’SDK, ma è in ritardo di un frame: al VSync nomina il buffer in cui il gioco sta per disegnare. Fare lo scanout da esso significava che il DMA e il rasterizzatore si contendevano la stessa metà della VRAM per tutto il frame. Leggere l’origine del display dalla struct del buffer del gioco stesso fa sì che i due lavorino su metà opposte per costruzione.

I bug che la PlayStation perdona

La PlayStation non ha protezione della memoria. Una lettura fuori posto restituisce ciò che trova, e una scrittura fuori posto finisce in memoria che spesso nessuno controlla. Anche la build per PC nasconde per lo più questi bug, perché le sue variabili statiche sono grandi e tolleranti. L’ESP32-S3 ha una MMU, memoria limitata e vicini che contano, quindi fare il porting su di essa si è rivelato un ottimo modo per trovare bug latenti.

Combattimento nel Laboratorio dell'Alchimia sulla console portatile

Come li ho trovati

Un backtrace di panic nomina la vittima, non il colpevole. Lo strumento che ha funzionato è stato OpenOCD tramite l’USB-JTAG integrato nel SoC, insieme a GDB:

  1. Resetta e metti in halt il SoC tramite OpenOCD.
  2. Imposta un breakpoint sull’handler di panic.
  3. Quando scatta, decodifica il PC reale dal frame di panic.
  4. Decodifica sempre rispetto all’ELF esatto che è stato flashato, perché gli indirizzi cambiano a ogni ricompilazione.

Animazione della palette fuori dai limiti

L’Alchemy Lab richiede l’animazione della palette (tileset & 0xFF) + 0x7FFF | 0x4000, che indicizza la voce 2 della tabella delle palette dello stage. La tabella ha una sola voce. La lettura fuori dai limiti è finita sulla tabella adiacente dei bank di sprite, che è stata poi registrata come descrittore di animazione della palette e scritta a ogni frame.

La correzione rifiuta i descrittori la cui estensione supera il buffer della palette. Lo stesso comportamento indefinito esiste a monte; il PC lo assorbe in un grande BSS.

Due stage, un solo HitDetection

Con gli stage linkati staticamente, 122 simboli globali erano definiti in più di uno stage. Gli header condivisi implementano cose come le collisioni e gli aggiornamenti delle entità, e sulla PlayStation veniva caricato solo uno stage alla volta. Gli archivi statici permettono al linker di risolvere silenziosamente ogni nome a una sola definizione. L’Alchemy Lab eseguiva l’HitDetection della Warp Room contro le proprie tabelle di entità, e la corruzione è apparsa uno strato più tardi.

La correzione è una lista generata di ogni simbolo condiviso da due stage, rinominato per stage con define -D.

Stanze che modificano la propria mappa

Le porte e i muri distruttibili scrivono sulla tile map della stanza. Con le mappe generate in flash, la prima porta ha mandato in crash il gioco. La correzione è un unico punto in cui il layer in primo piano della stanza attiva viene copiato in un buffer PSRAM scrivibile.

Il nome del nemico

Il riquadro del nome del nemico in fondo allo schermo, disegnato dallo stesso codice BottomCornerText che prima sovrascriveva lo stack

Con la reliquia Faerie Scroll, il gioco stampa il nome del nemico che colpisci. BottomCornerText scansiona la stringa alla ricerca del terminatore FF 00 della PlayStation. Le stringhe su PC sono normali stringhe C, quindi la scansione è andata oltre la fine e ha sovrascritto un buffer di stack da 64 byte. Il sintomo era “si blocca appena tocco un nemico”. La correzione limita la scansione.

Una race che sulla PlayStation non esisteva

L’audio gira sul secondo core, e il pull audio avanzava i contatori root. Quei contatori fanno scattare l’handler VSync del gioco, gli upload delle texture e le callback della coda GPU. Sulla PlayStation quelli sono interrupt sulla stessa CPU. Qui giravano concorrentemente con il gioco sull’altro core.

La correzione accumula i tick dei contatori sul core 1 e fa scattare gli handler sul thread del gioco.

Input che non arrivava mai

La segnalazione era “non riesco a saltare”. Tre bug erano impilati:

La console portatile: bring-up hardware su un XIAO ESP32S3 Sense

La console portatile finita vista dall'alto: pannello ILI9341, stick di un gimbal per droni, pulsanti e lo XIAO su basetta millefori

Il retro della console portatile: cablaggio punto-punto sulla basetta millefori e lo slot SD del pannello stesso (inutilizzato). Il modulo fotocamera dello XIAO, rimosso, è accanto

Il fronte della console portatile accanto al modulo fotocamera dello XIAO, che il port non usa ed è stato rimosso

L’obiettivo era una console portatile. È lo stesso hardware che in seguito ha eseguito Metal Gear Solid: SOTN è stato il primo gioco su di essa. I componenti:

Lo XIAO ha undici pin utilizzabili. Il pannello ne richiede cinque (SCK, MOSI, MISO, CS, DC) più una linea di reset, e lo stick richiede due pin ADC. Restano tre pin per otto pulsanti.

Sei dei pulsanti condividono un pin ADC tramite una scala di resistori: un pull-up da 10k e, per ogni pulsante, un resistore verso massa (0 Ω, 2k2, 4k7, 10k, 22k, 47k). Ogni pulsante produce una tensione diversa. Il limite è che due pulsanti premuti insieme vengono letti come quello inferiore. Alla coppia che ogni gioco tiene premuta insieme (attacco e salto in SOTN) vanno i due pin dedicati rimanenti.

Lo schema di cablaggio completo, la lista dei componenti e la mappa dei pulsanti sono nella guida hardware nel repository.

Cablaggio della console portatile, disegnato con l'artwork dei componenti di Velxio: XIAO ESP32S3 Sense, pannello ILI9341, stick analogico, scala di resistori a sei pulsanti e due pulsanti diretti

Il bring-up è stata una sua propria lista di lezioni. Prima di toccare il gioco, ho scritto un firmware di collaudo separato che disegna barre colorate e mostra sullo schermo ogni pulsante e lo stick:

La console portatile: display, input e un crash catturato da un watchpoint

Tutto sulla scheda SD

8 MB di flash non possono contenere un’app da 3 MB accanto a 3,7 MB di dati di gioco, quindi sulla console portatile tutti i dati provengono dalla scheda microSD. Quella scheda condivide il bus SPI con il pannello, il che in seguito ha causato un suo proprio crash (vedi “Il bus SPI condiviso” più sotto).

Due trappole specifiche di questa scheda

GPIO43 è sia il pin data/command del pannello sia il TX di UART0. Il codice Waveshare installava il driver UART per la sua console seriale. Farlo restituisce silenziosamente il pin all’UART, e il pannello smette di distinguere i comandi dai pixel. La console portatile usa l’USB nativo solo per la sua console.

printf su USB nativo si blocca quando nessuno legge. Con un PC collegato non te ne accorgi mai. Scollegato, il buffer di trasmissione si riempie in pochi secondi e il gioco si ferma dentro una printf, su un dispositivo pensato per funzionare scollegato. Tutto l’output, incluso il logging di ESP-IDF stesso, ora passa attraverso un wrapper che scrive con un timeout pari a zero e scarta ciò che nessuno legge.

Sfarfallio, poi rettangoli che scivolano

La prima build sfarfallava. Ho ipotizzato che il clock SPI fosse troppo lento e l’ho alzato da 40 a 80 MHz. Ha aiutato un po’, ma strumentare la scansione ha mostrato cosa stava realmente accadendo:

Cronologia: il gioco disegna un frame ogni 16,7 ms nei buffer A e B mentre il pannello impiega circa 24 ms a riceverne uno, quindi il gioco ridisegna un buffer che il DMA sta ancora inviando

Questo era un problema di coerenza dei buffer. La soluzione è stata il percorso di copia che il port Waveshare aveva mantenuto come rete di sicurezza: copiare il frame finito, poi inviare la copia. La mia prima versione aveva due bug:

Anche dopo entrambe le correzioni i rettangoli restavano. Riportare il clock SPI a 40 MHz li ha fatti sparire: i fili jumper non reggevano gli 80 MHz, e uno slittamento di un byte sposta un’intera banda di 8 linee. Il clock SPI dell’S3 divide una sorgente da 80 MHz, quindi le uniche opzioni sono 80 e 40 MHz, senza nulla in mezzo.

Con i fili come collo di bottiglia, l’opzione rimanente era inviare meno dati. Ogni banda di 8 linee viene identificata tramite fingerprint mentre viene convertita in RGB565, e le bande identiche a ciò che il pannello già mostra vengono saltate. I frame scartati sono passati da circa il 40% al 6%. Questo dà all’incirca 55 fps sullo schermo quando la telecamera è ferma, mentre la logica di gioco resta a piena velocità.

Il crash catturato da un watchpoint hardware

La segnalazione successiva è stata “ho premuto START e si è riavviato”. Prima ce n’era stata un’altra: “è apparso del testo, poi lo schermo è diventato bianco, poi si è riavviato.”

Il panic era in ClearOTag, chiamato dal loop principale con un puntatore all’ordering table pari a 0x4b8. Il loop principale avanza con g_CurrentBuffer = g_CurrentBuffer->next, quindi andando a ritroso, qualcosa aveva scritto 0x44 nel link next di uno dei due buffer GPU.

L’ESP32-S3 ha due watchpoint hardware. Li ho armati entrambi, uno su ciascun campo next dei buffer, due secondi dopo il boot, ben dopo l’unica scrittura legittima. Premendo START la CPU si è fermata sullo store del colpevole:

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

Il menu di pausa prende il prossimo sprite libero con &g_CurrentBuffer->sprite[g_GpuUsage.sp] e non controlla mai il limite. sprite[] è l’ultimo campo di un buffer GPU, e il secondo buffer inizia subito dopo, a partire dal suo link next. Disegnare il testo delle statistiche ha superato i 512 sprite, e AddPrim ha scritto un’intestazione di primitiva sopra il link. Lo stesso gioco controlla g_GpuUsage.sp < MAX_SPRT_COUNT altrove, ma il menu no. Due righe l’hanno risolto.

I due buffer GPU sono adiacenti in memoria; il 513° sprite del menu di pausa finisce sul link next del buffer successivo, cosa che un watchpoint hardware ha catturato

Il bus SPI condiviso

Subito dopo, è apparso un crash diverso durante i caricamenti degli stage: un’asserzione nel driver SPI, running_cmd == 0. La scheda avviava un comando mentre un trasferimento DMA del pannello era ancora sul filo. Sulla scheda Waveshare la scheda trasmetteva solo musica opzionale; sulla console portatile trasporta tutto.

La soluzione è un mutex:

Strumentare senza ingannare se stessi

Due lezioni da questo tratto:

Eseguire ogni build senza flashare la scheda

Dopo diverse ore di tentativi ed errori, flashare la scheda dopo ogni modifica era la parte più lenta del ciclo: scrivere l’immagine via USB, aspettare il riavvio, riconnettere la porta seriale senza resettare di nuovo la scheda, leggere il log, ripetere. Così, per eseguire e verificare ogni build ho usato velxio-cli, il client da riga di comando di Velxio. Prende lo stesso .bin prodotto dalla mia toolchain, lo esegue sull’ESP32-S3 simulato di Velxio per una quantità fissa di tempo simulato e restituisce l’output seriale, così potevo leggere contatori e tempi senza toccare l’hardware. La scheda riceveva una nuova immagine solo quando una build lo meritava.

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

Installarlo ed eseguire una prima esecuzione richiede pochi minuti: vedi la guida rapida di velxio-cli.

Aggiungere uno stage: di nuovo il budget di memoria

Uscendo dall’Alchemy Lab si arriva al Castle Entrance (NP3). Aggiungerlo ha fatto traboccare la RAM interna di 160 KB. La grafica compressa dello stage, le palette, le tile map e le tabelle degli sprite erano dichiarate scrivibili, quindi venivano copiate in RAM all’avvio. Marcarle const le ha spostate in flash. Il port PC upstream aveva già aggiunto NP3, e quel commit è passato senza problemi.

Altre due scoperte:

I file generati vengono rigenerati dal disco di ciascun utente, quindi le modifiche const vivono in uno script (esp32/const_stage_tables.py) anziché nei sorgenti generati.

Consigli per un port simile

Stato e prossimi passi

Un altro combattimento nel Laboratorio di Alchimia, sulla console portatile

Il codice è su GitHub all’indirizzo davidmonterocrespo24/sotn-decomp (branch esp32-port). Lo schema di cablaggio e la lista dei componenti sono nella guida hardware. Serve una propria copia del gioco; il repository non contiene dati di gioco.

Grazie ai contributori della decompilazione di SOTN, e a Xeeynamo per psyz. Questo progetto è costruito sul loro lavoro.


Condividi su:
David Montero

Scritto da

David Montero

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

GitHub velxio.dev

Articoli correlati


Articolo successivo
Metal Gear Solid in esecuzione nativamente sull'ESP32-S3