
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:
- Compilare il gioco per Xtensa.
- Sostituire il backend PC di psyz con uno per il SoC.
- Far entrare tutto.
Il SoC di destinazione è l’ESP32-S3:
- Due core Xtensa LX7 a 240 MHz.
- 512 KB di SRAM interna.
- 8 MB di PSRAM octal (esterna, molto più lenta).
- 16 MB di flash sulla prima scheda e 8 MB sulla console portatile.
- Nessuna GPU.
Architettura
I livelli, dal gioco fino all’hardware:
- Gioco: motore decompilato, codice del giocatore e delle armi, stage
- psyz: reimplementazione dell’SDK con PSYZ_RENDERER=soft
- Livello piattaforma (esp32/main): VSync, root counter, input, file FAT, audio, scanout LCD
- Hardware ESP32-S3: core LX7, SRAM, PSRAM, flash, LCD SPI
- Gioco: il motore decompilato (
src/dra), il codice del giocatore e delle armi, e ogni area del castello (“stage”). Questi sono invariati a parte le correzioni di bug. - psyz: la reimplementazione dell’SDK. Vi ho aggiunto un terzo renderer,
PSYZ_RENDERER=soft. È un parser di pacchetti più un core rasterizzatore senza dipendenze da SDL, quindi lo stesso file si compila su Windows (per testarlo contro il riferimento GPU) e sul SoC. - Livello piattaforma (
esp32/main): pacing VSync a 59,94 Hz, i root counter che guidano il sequencer audio, input del controller, accesso ai file tramite FAT, missaggio audio sul secondo core e scanout LCD.
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

Il primo link è fallito per 3,34 MB di RAM interna. La maggior parte di ciò non era colpa del gioco:
g_TileDefDataPoolera dichiarato comeTileDefinition[0x40][4][0x1000]. Su puntatori a 32 bit sono da 16 a 32 MB per contenere circa 1 MB di dati. Ridefinirlo come byte ha risolto la voce più grande.- La libreria audio allocava un array di 512 KB sullo stack. Funziona su PC; su un microcontrollore va in crash immediatamente.
- I buffer di grandi dimensioni toccati solo occasionalmente sono stati spostati in PSRAM con
EXT_RAM_BSS_ATTR. - Le tabelle di sola lettura sono state marcate
const, il che le colloca in flash.
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:
- 207 KB di dati inizializzati in RAM interna.
- 43 KB di dati azzerati in RAM interna.
- 55 KB di codice in IRAM.
- 3 MB di statici in PSRAM.
- Circa 100 KB di heap interno libero a runtime.
Il rasterizzatore software, e come arrivare a 60 fps

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:
| Passo | Risultato |
|---|---|
| Edge stepping dei triangoli: divisioni, poi accumulatori a 64 bit, poi 32 bit 16.16 | Il 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 notify | Il gioco non attende mai l’SPI |
| Cache della palette in RAM interna, cicli di rasterizzazione in IRAM | Warp Room a 31 fps |
| Fast path del layer di tile: 4 texel per lettura, coppie di pixel come store a 32 bit | Da 10,4 ms a 6,1 ms per frame |
| Scanout dal buffer che il gioco ha appena finito di disegnare | Nessuna copia, un frame in meno di latenza |
| PSRAM e flash da 80 a 120 MHz | Da 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.

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:
- Resetta e metti in halt il SoC tramite OpenOCD.
- Imposta un breakpoint sull’handler di panic.
- Quando scatta, decodifica il PC reale dal frame di panic.
- 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

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 porta seriale USB scarta i byte in arrivo mentre DTR è basso, e il mio script della tastiera lo teneva basso per evitare di resettare la scheda.
- La console era configurata per la UART mentre il cavo era sull’USB nativa.
- Un pin di un pulsante non collegato veniva letto come permanentemente premuto, il che teneva CROSS abbassato per sempre.
La console portatile: bring-up hardware su un XIAO ESP32S3 Sense



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:
- Seeed XIAO ESP32S3 Sense: lo stesso SoC ESP32-S3 con 8 MB di PSRAM, 8 MB di flash e uno slot microSD sulla sua scheda di espansione.
- Pannello SPI ILI9341 320×240: la risoluzione nativa della PlayStation, quindi non serve alcun ridimensionamento.
- Uno stick analogico preso da un controller per droni.
- Otto pulsanti.
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.
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:
- Il pannello era bianco, e si affievoliva quando premevo i pulsanti. Non aveva un filo di massa, quindi si alimentava attraverso i diodi di protezione sui suoi pin dati. Aggiungere GND ha risolto all’istante.
- Il pannello era un ILI9341 invece dell’ILI9488 che avevo previsto. È un controller diverso con un formato pixel diverso (RGB565 su SPI invece di RGB666). Il suo driver non è integrato in ESP-IDF e proviene dal registro dei componenti.
- Una vera linea di reset conta. Con RESET forzato alto e solo un reset software, il pannello non si avviava in modo affidabile.
- Pulsanti a quattro piedini: due piedini sullo stesso lato sono già collegati internamente. Se colleghi quelli, il pulsante è sempre premuto. Usa i piedini in diagonale.
- Lo stick andava alla deriva da solo. Un gimbal per droni si posa dove lo mettono le sue molle (2317 su 4095 nel mio caso, dove potresti assumere 2048), e l’ADC è rumoroso. La correzione:
- Calibra il centro all’avvio.
- Impara la corsa di ogni asse man mano che viene usato.
- Media 8 campioni.
- Applica una zona morta di 60 conteggi, quattro volte il rumore misurato a riposo.
- L’asse Y era invertito due volte. Il firmware di collaudo negava Y in modo che “su muove il punto in su” su uno schermo il cui Y cresce verso il basso. Il gioco prende bit di direzione, dove su è semplicemente su. Trasportare la convenzione dello schermo l’ha invertito di nuovo.
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:
- Un frame impiegava circa 24 ms per essere inviato.
- Il gioco ne produceva uno ogni 16,7 ms.
- Quindi il gioco doppiava il pannello: scambiava i buffer e iniziava a ridisegnare quello che il DMA stava ancora trasmettendo.
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:
- Copiava solo le righe del rettangolo di clip corrente del rasterizzatore, che un effetto può ridurre a poche righe. Lo schermo si bloccava mentre il gioco continuava a girare.
- Con due buffer di copia, poteva sovrascrivere quello ancora in fase di invio. Su una scena con scorrimento orizzontale questo si manifesta come rettangoli che scivolano lateralmente.
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.
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:
- Il pannello lo tiene per un frame e svuota il suo DMA in corso prima di rilasciarlo.
- La scheda lo acquisisce attorno a ogni comando, avvolgendo l’hook
do_transactiondel driver.
Strumentare senza ingannare se stessi
Due lezioni da questo tratto:
- Il mio listener seriale stava resettando la scheda. Aprire la porta nel modo predefinito asserisce DTR/RTS, che sull’USB nativo dell’S3 sono reset e boot-select. Ogni volta che mi connettevo per ispezionare uno schermo bloccato, riavviavo la scheda e leggevo un log sano. La soluzione è costruire l’oggetto porta, abbassare entrambe le linee e poi aprirla.
- Misura l’intera operazione. La mia prima misurazione della scansione separava “attesa” e “conversione” ma ometteva la chiamata di disegno stessa, che si blocca quando la coda SPI è piena. Riportava 5 ms per un frame che ne impiegava 24.
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:
- Gli sprite di Richter (il personaggio del prologo) erano 85 KB di dati scrivibili in RAM, mai usati nel gioco normale. Marcarli di nuovo
constha significato che, dopo aver aggiunto NP3, l’uso della RAM interna era inferiore rispetto a prima. - Il linker ha segnalato 16 simboli duplicati tra NP3 e gli altri stage. Ce n’erano 31. Confrontando le tabelle dei simboli con
nmho trovato gli altri 15, inclusiEntitySlograeEntityGaibon: codice dei boss che NP3 avrebbe silenziosamente preso in prestito dall’Alchemy Lab.
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
- Scegli una decompilazione che compili già un target portabile. È ciò che rende possibile un port nativo dove un emulatore non lo è.
- Fai in modo che la scheda ti dica cosa non va. Usa contatori, tempi suddivisi nelle loro parti e checksum dei frame. La maggior parte delle mie ipotesi sbagliate è stata smentita da un numero.
- Un backtrace nomina la vittima. Per la corruzione di memoria, un watchpoint hardware sull’indirizzo corrotto nomina il colpevole.
- La memoria è il budget di prestazioni. Su questo SoC, meno accessi alla PSRAM battono sempre l’aritmetica ingegnosa.
- La tolleranza dell’hardware originale nasconde i bug. Aspettati di trovarne alcuni.
Stato e prossimi passi

- Scheda Waveshare: Laboratorio di Alchimia a 60 fps.
- Console portatile: logica di gioco a piena velocità, schermo a circa 55 fps quando la telecamera è ferma.
- Stage collegati: Laboratorio di Alchimia, Sala di Teletrasporto e il menu del titolo/salvataggio. Ingresso del Castello è collegato e attende il suo primo test sulla console portatile.
- Audio: gli effetti funzionano, e la musica XA viene riprodotta quando l’immagine del disco è sulla scheda.
- Prossimi passi: altri stage (il budget di memoria ora ha spazio), e un cablaggio saldato per la console portatile per riprovare l’SPI a 80 MHz.
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.