Zurück

Castlevania: Symphony of the Night läuft nativ auf dem ESP32-S3

Der XIAO ESP32S3 Handheld läuft mit Castlevania: Symphony of the Night im Alchemy Laboratory

Im Metal Gear Solid-Artikel habe ich die Handheld-Konsole gezeigt, die ich um ein XIAO ESP32S3 Sense herum gebaut habe. Castlevania: Symphony of the Night (SOTN) ist das andere PlayStation-Spiel, das darauf läuft, und dasjenige, das zuerst kam: Es lief mit 60 fps auf einem Waveshare ESP32-S3 Entwicklungsboard, bevor der Handheld existierte, und zog später auf diesen um.

Es war aus demselben Grund möglich wie Metal Gear Solid. Die Community-Dekompilierung von SOTN lässt sich als portables C kompilieren, sodass das Spiel für den ESP32-S3 kompiliert statt emuliert werden kann.

Gameplay auf dem Handheld: auf YouTube ansehen.

Dieser Artikel behandelt, wie der Port strukturiert ist, wie das Spiel in den Speicher des SoC passt, wie der Software-Rasterizer 60 fps erreichte, die Bugs, die die Original-Hardware verborgen hatte, und eine Handheld-Konsole, die ich um ein Seeed XIAO ESP32S3 Sense herum gebaut habe.

Warum ein nativer Port möglich ist

Eine PlayStation auf einem ESP32-S3 zu emulieren ist außer Reichweite. Ein Emulator muss eine MIPS-CPU und eine GPU Instruktion für Instruktion interpretieren, was ein Vielfaches der Leistung erfordert, die der SoC hat.

Portieren ist anders. Das SOTN-Dekompilierungsprojekt hat das Spiel als C-Quellcode rekonstruiert, der zu einer PC-Executable kompiliert. Die Spiellogik ist gewöhnliches C, und das Einzige, was es an die Konsole bindet, ist Sonys SDK: die Bibliotheksaufrufe, die Polygone zeichnen, Texturen hochladen, den Controller lesen und Sounds abspielen. Der PC-Build ersetzt dieses SDK durch psyz, eine Reimplementierung auf Basis von SDL.

Die Aufgabe hat also drei Teile:

  1. Das Spiel für Xtensa kompilieren.
  2. psyz’ PC-Backend durch eines für den SoC ersetzen.
  3. Alles passend machen.

Der Ziel-SoC ist der ESP32-S3:

Architektur

Die Schichten, vom Spiel bis hinunter zur Hardware:

  1. Spiel: dekompilierte Engine, Spieler- und Waffencode, Stages
  2. psyz: SDK-Reimplementierung mit PSYZ_RENDERER=soft
  3. Plattformschicht (esp32/main): VSync, Root-Counter, Eingabe, FAT-Dateien, Audio, LCD-Scanout
  4. ESP32-S3-Hardware: LX7-Kerne, SRAM, PSRAM, Flash, SPI-LCD

Die Schichten des Ports und die beiden Kerne: das Spiel und der Rasterizer auf Kern 0, LCD-Scanout und Audio auf Kern 1, und das Panel und die microSD, die sich auf dem Handheld SPI2 teilen

Zwei Designentscheidungen prägten alles andere.

VRAM bleibt der eigene Puffer des Spiels. Die PlayStation hat 1 MB Videospeicher, 1024×512 Pixel bei 16-Bit-Farbe. Die Dekompilierung modelliert ihn bereits als einfaches Array. Der Rasterizer zeichnet direkt hinein (im PSRAM), und das LCD wird daraus gespeist. Es gibt keine Schattenkopien und keine Formatkonvertierungen außer der finalen in das RGB565 des Panels.

Stages werden statisch gelinkt. Auf einem PC ist jeder Schlossbereich eine DLL, die bei Bedarf geladen wird. Der ESP32-S3 hat keinen dynamischen Linker, also wird jeder Bereich in die Firmware kompiliert, und eine kleine Tabelle ordnet einen Stage-Namen seiner Init-Funktion zu. Diese Entscheidung verursachte zweimal Probleme, wie unten in „Bugs, die die PlayStation verzeiht” und „Eine Stage hinzufügen” beschrieben.

Ein PlayStation-Spiel in 512 KB unterbringen

Alucard neben einer der Statuen des Labors

Der erste Link schlug um 3,34 MB internen RAM fehl. Das meiste davon war nicht die Schuld des Spiels:

Letzteres hat eine Falle: Manche „schreibgeschützten” Daten werden geschrieben. Das SDK aktualisiert die Sound-Bank-Header an Ort und Stelle, wenn eine Bank geöffnet wird. Als const deklariert liegen sie im Flash, und das Schreiben in das Flash durch den Cache ist ein Hard Fault („Dbus write to cache”). Das funktionierende Muster besteht darin, einen const-Master im Flash zu behalten, ihn beim Start in einen PSRAM-Puffer zu kopieren und das Spiel die Kopie beschreiben zu lassen.

Nach 28 Runden des Linkens waren die Zahlen:

Der Software-Rasterizer und der Weg zu 60 fps

Die Flammen des Labors: halbtransparente Effekte, gezeichnet vom Software-Rasterizer

SOTN ist ein 2D-Spiel, und seine Primitivenvielfalt ist klein: texturierte und Gouraud-Quads, 16×16-Sprites für die Tile-Layer, Rechtecke und Linien. Es gibt kein 3D und keine Geometrie-Transform-Engine, was einen Software-Rasterizer realistisch machte.

Der Rasterizer ist bit-exakt gegenüber dem GPU-Referenz-Build. Ich habe Frame-Checksummen zwischen dem PC-Build und dem Board nach jeder Optimierung verglichen.

Der Großteil der Frame-Zeit ging auf Speicherzugriffe. Jeder texturierte Pixel liest ein Texel und einen Paletteneintrag aus dem PSRAM und schreibt einen Pixel ins PSRAM. Die Optimierungen, die zählten, reduzieren alle diesen Verkehr:

SchrittErgebnis
Triangle-Edge-Stepping: Divisionen, dann 64-Bit-Akkumulatoren, dann 32-Bit 16.1664-Bit war langsamer auf einem 32-Bit-Kern; 16.16 wurde ausgeliefert
Scanout auf Kern 1 verschoben, gespeist durch ein NotifyDas Spiel wartet nie auf SPI
Paletten-Cache im internen RAM, Raster-Loops im IRAMWarp Room bei 31 fps
Tile-Layer-Fast-Path: 4 Texel pro Lesezugriff, Pixelpaare als 32-Bit-Stores10,4 ms auf 6,1 ms pro Frame
Scanout aus dem Puffer, den das Spiel gerade fertig gezeichnet hatKeine Kopie, ein Frame weniger Latenz
PSRAM und Flash von 80 auf 120 MHz19,4 ms auf 16,4 ms pro Frame: 60 fps

Ich habe außerdem einen Texture-Row-Cache im Triangle-Rasterizer ausprobiert und wieder verworfen. Er brachte nichts, weil GCC die Ladevorgänge bereits hochgezogen hatte.

Die Änderung, aus dem fertigen Puffer zu scannen, bedarf einer Erklärung. Die naheliegende Quelle für das Display ist der aktuelle Display-Bereich des SDK, aber dieser hinkt einen Frame hinterher: Bei VSync benennt er den Puffer, in den das Spiel gerade eben zeichnen wird. Daraus zu scannen bedeutete, dass DMA und Rasterizer während des gesamten Frames um dieselbe Hälfte des VRAM kämpften. Den Display-Ursprung aus der eigenen Puffer-Struktur des Spiels zu lesen, lässt beide konstruktionsbedingt auf gegenüberliegenden Hälften arbeiten.

Bugs, die die PlayStation verzeiht

Die PlayStation hat keinen Speicherschutz. Ein verirrter Lesezugriff liefert zurück, was gerade dort steht, und ein verirrter Schreibzugriff landet in Speicher, den oft niemand überprüft. Der PC-Build verbirgt diese Bugs ebenfalls größtenteils, weil seine Statics groß und nachsichtig sind. Der ESP32-S3 hat eine MMU, knappen Speicher und Nachbarn, auf die es ankommt, weshalb sich das Portieren darauf als sehr guter Weg erwies, latente Bugs zu finden.

Kampf im Alchemy Laboratory auf dem Handheld

Wie ich sie gefunden habe

Ein Panic-Backtrace nennt das Opfer, nicht den Täter. Das Werkzeug, das funktionierte, war OpenOCD über das eingebaute USB-JTAG des SoC, zusammen mit GDB:

  1. Den SoC über OpenOCD zurücksetzen und anhalten.
  2. Einen Breakpoint auf den Panic-Handler setzen.
  3. Wenn er auslöst, den echten PC aus dem Panic-Frame dekodieren.
  4. Immer gegen genau das ELF dekodieren, das geflasht wurde, denn die Adressen verschieben sich bei jedem Rebuild.

Palettenanimation außerhalb der Grenzen

Das Alchemy Lab fordert die Palettenanimation (tileset & 0xFF) + 0x7FFF | 0x4000 an, was Eintrag 2 der Palettentabelle der Stage indiziert. Die Tabelle hat einen Eintrag. Der Out-of-Bounds-Lesezugriff landete auf der benachbarten Sprite-Bank-Tabelle, die dann als Palettenanimations-Deskriptor registriert und in jedem Frame beschrieben wurde.

Der Fix weist Deskriptoren zurück, deren Ausdehnung den Palettenpuffer überschreitet. Dasselbe undefinierte Verhalten existiert upstream; der PC absorbiert es in einem großen BSS.

Zwei Stages, ein HitDetection

Da die Stages statisch gelinkt werden, waren 122 globale Symbole in mehr als einer Stage definiert. Gemeinsam genutzte Header implementieren Dinge wie Kollision und Entity-Updates, und auf der PlayStation wurde nie mehr als eine Stage geladen. Statische Archive lassen den Linker jeden Namen stillschweigend auf eine Definition auflösen. Das Alchemy Lab führte das HitDetection des Warp Room gegen seine eigenen Entity-Tabellen aus, und die Korruption zeigte sich eine Ebene später.

Der Fix ist eine generierte Liste jedes Symbols, das zwei Stages teilen, pro Stage mit -D-Defines umbenannt.

Räume, die ihre eigene Map verändern

Türen und zerbrechliche Wände schreiben in die Tile-Map des Raums. Mit den generierten Maps im Flash stürzte das Spiel bei der ersten Tür ab. Der Fix ist ein einziger Punkt, an dem die Vordergrundebene des aktiven Raums in einen beschreibbaren PSRAM-Puffer kopiert wird.

Der Gegnername

Die Gegnernamen-Box am unteren Bildschirmrand, gezeichnet von demselben BottomCornerText-Code, der früher den Stack überlief

Mit dem Faerie Scroll Relikt gibt das Spiel den Namen des Gegners aus, den man getroffen hat. BottomCornerText durchsucht den String nach dem FF 00-Terminator der PlayStation. PC-Strings sind einfache C-Strings, also lief die Suche über das Ende hinaus und überschrieb einen 64-Byte-Stack-Puffer. Das Symptom war “es friert ein, sobald ich einen Gegner berühre.” Der Fix begrenzt die Suche.

Eine Race Condition, die auf der PlayStation nicht existierte

Audio läuft auf dem zweiten Kern, und der Audio-Pull trieb die Root-Counter voran. Diese Counter lösen den VSync-Handler des Spiels, Textur-Uploads und GPU-Queue-Callbacks aus. Auf der PlayStation sind das Interrupts auf derselben CPU. Hier liefen sie gleichzeitig mit dem Spiel auf dem anderen Kern.

Der Fix sammelt Counter-Ticks auf Kern 1 und löst die Handler im Spiel-Thread aus.

Input, der nie ankam

Die Meldung war “ich kann nicht springen.” Drei Bugs stapelten sich:

Der Handheld: Hardware-Bring-up auf einem XIAO ESP32S3 Sense

Der fertige Handheld von oben: ILI9341-Panel, Drohnen-Gimbal-Stick, Buttons und das XIAO auf Lochrasterplatine

Die Rückseite des Handhelds: Punkt-zu-Punkt-Verdrahtung auf der Lochrasterplatine und der eigene SD-Slot des Panels (ungenutzt). Das entfernte Kameramodul des XIAO liegt daneben

Die Vorderseite des Handhelds neben dem Kameramodul des XIAO, das der Port nicht nutzt und das abgenommen wurde

Das Ziel war eine Handheld-Konsole. Es ist dieselbe Hardware, auf der später Metal Gear Solid lief: SOTN war das erste Spiel darauf. Die Teile:

Das XIAO hat elf nutzbare Pins. Das Panel braucht fünf (SCK, MOSI, MISO, CS, DC) plus eine Reset-Leitung, und der Stick braucht zwei ADC-Pins. Das lässt drei für acht Buttons übrig.

Sechs der Buttons teilen sich einen ADC-Pin über eine Widerstandsleiter: ein 10k-Pull-up und pro Button ein Widerstand gegen Masse (0 Ω, 2k2, 4k7, 10k, 22k, 47k). Jeder Button erzeugt eine andere Spannung. Die Einschränkung ist, dass zwei gleichzeitig gehaltene Buttons als der niedrigere gelesen werden. Das Paar, das jedes Spiel zusammen hält (Angriff und Sprung in SOTN), bekommt die zwei verbleibenden dedizierten Pins.

Der vollständige Schaltplan, die Teileliste und die Button-Belegung sind im Hardware-Leitfaden im Repository.

Verdrahtung des Handhelds, gezeichnet mit Velxios Bauteil-Grafiken: XIAO ESP32S3 Sense, ILI9341-Panel, Analogstick, Sechs-Button-Widerstandsleiter und zwei direkte Buttons

Das Bring-up war eine eigene Liste von Lektionen. Bevor ich das Spiel anfasste, schrieb ich eine separate Shakedown-Firmware, die Farbbalken zeichnet und jeden Button und den Stick auf dem Bildschirm anzeigt:

Der Handheld: Display, Eingabe und ein Crash, den ein Watchpoint aufgedeckt hat

Alles auf der SD-Karte

8 MB Flash können keine 3 MB große App neben 3,7 MB Spieldaten halten, also kommen auf dem Handheld alle Daten von der microSD-Karte. Diese Karte teilt sich den SPI-Bus mit dem Panel, was später einen eigenen Crash verursachte (siehe „Der geteilte SPI-Bus” weiter unten).

Zwei Fallen, die spezifisch für dieses Board sind

GPIO43 ist sowohl der Data/Command-Pin des Panels als auch UART0 TX. Der Waveshare-Code installierte den UART-Treiber für seine serielle Konsole. Dadurch wird der Pin stillschweigend wieder dem UART übergeben, und das Panel kann Kommandos nicht mehr von Pixeln unterscheiden. Der Handheld nutzt für seine Konsole ausschließlich natives USB.

printf über natives USB blockiert, wenn niemand liest. Mit angeschlossenem PC fällt das nie auf. Ohne Stecker füllt sich der Sendepuffer in wenigen Sekunden, und das Spiel bleibt in einem printf hängen — auf einem Gerät, das dafür gedacht ist, ohne Stecker zu laufen. Alle Ausgaben, einschließlich des Loggings von ESP-IDF selbst, laufen jetzt über einen Wrapper, der mit einem Timeout von null schreibt und verwirft, was niemand liest.

Flackern, dann gleitende Rechtecke

Der erste Build flackerte. Ich nahm an, der SPI-Takt sei zu langsam, und erhöhte ihn von 40 auf 80 MHz. Das half ein wenig, aber die Instrumentierung des Scanouts zeigte, was wirklich passierte:

Zeitachse: Das Spiel zeichnet alle 16,7 ms einen Frame in die Puffer A und B, während das Panel etwa 24 ms braucht, um einen zu empfangen, sodass das Spiel einen Puffer neu zeichnet, den die DMA noch sendet

Das war ein Problem der Pufferkohärenz. Die Lösung war der Kopierpfad, den der Waveshare-Port als Sicherheitsnetz beibehalten hatte: den fertigen Frame kopieren und dann die Kopie senden. Meine erste Version davon hatte zwei Bugs:

Auch nach beiden Korrekturen blieben die Rechtecke. Den SPI-Takt wieder auf 40 MHz zu senken, ließ sie verschwinden: Die Jumper-Kabel hielten 80 MHz nicht aus, und ein Byte-Versatz verschiebt ein ganzes Band von 8 Zeilen. Der SPI-Takt des S3 teilt eine 80-MHz-Quelle, also gibt es nur die Optionen 80 und 40 MHz, nichts dazwischen.

Da die Kabel der Flaschenhals waren, blieb als Option, weniger Daten zu senden. Jedes 8-Zeilen-Band wird beim Umwandeln in RGB565 mit einem Fingerabdruck versehen, und Bänder, die identisch mit dem sind, was das Panel bereits zeigt, werden übersprungen. Die verworfenen Frames gingen von etwa 40 % auf 6 % zurück. Das ergibt ungefähr 55 fps auf dem Bildschirm, wenn die Kamera stillsteht, während die Spiellogik mit voller Geschwindigkeit weiterläuft.

Der Crash, den ein Hardware-Watchpoint aufdeckte

Der nächste Bericht lautete: „Ich habe START gedrückt und es startete neu.” Vorher hatte es einen anderen gegeben: „Text erschien, dann wurde der Bildschirm weiß, dann startete es neu.”

Die Panic lag in ClearOTag, aufgerufen aus der Hauptschleife mit einem Ordering-Table-Zeiger von 0x4b8. Die Hauptschleife rückt mit g_CurrentBuffer = g_CurrentBuffer->next vor, also hatte rückwärts betrachtet etwas 0x44 in den next-Link eines der beiden GPU-Puffer geschrieben.

Der ESP32-S3 hat zwei Hardware-Watchpoints. Ich bewaffnete beide, einen auf das next-Feld jedes Puffers, zwei Sekunden nach dem Boot, also weit nach dem einzigen legitimen Schreibzugriff. Das Drücken von START stoppte die CPU beim Store des Übeltäters:

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

Das Pausenmenü nimmt den nächsten freien Sprite mit &g_CurrentBuffer->sprite[g_GpuUsage.sp] und prüft nie die Grenze. sprite[] ist das letzte Feld eines GPU-Puffers, und der zweite Puffer beginnt direkt danach, angefangen mit seinem next-Link. Das Zeichnen des Statustexts überschritt 512 Sprites, und AddPrim schrieb einen Primitive-Header über den Link. Dasselbe Spiel prüft an anderer Stelle g_GpuUsage.sp < MAX_SPRT_COUNT, aber das Menü tat es nicht. Zwei Zeilen behoben es.

Die beiden GPU-Puffer liegen im Speicher direkt hintereinander; der 513. Sprite des Pausenmenüs landet auf dem next-Link des nächsten Puffers, was ein Hardware-Watchpoint aufdeckte

Der geteilte SPI-Bus

Direkt danach trat bei Stage-Ladevorgängen ein anderer Crash auf: eine Assertion im SPI-Treiber, running_cmd == 0. Die Karte startete ein Kommando, während noch eine Panel-DMA-Übertragung auf der Leitung war. Auf dem Waveshare-Board streamte die Karte nur optionale Musik; auf dem Handheld trägt sie alles.

Die Lösung ist ein Mutex:

Instrumentieren, ohne sich selbst zu täuschen

Zwei Lektionen aus diesem Abschnitt:

Jeden Build ausführen, ohne das Board zu flashen

Nach mehreren Stunden Versuch und Irrtum war das Flashen des Boards nach jeder Änderung der langsamste Teil der Schleife: das Image über USB schreiben, auf den Neustart warten, den seriellen Port neu verbinden, ohne das Board erneut zurückzusetzen, das Log lesen, wiederholen. Um jeden Build auszuführen und zu prüfen, nutzte ich also velxio-cli, den Kommandozeilen-Client von Velxio. Er nimmt dieselbe .bin, die meine Toolchain erzeugt hat, führt sie für eine feste Menge simulierter Zeit auf Velxios simuliertem ESP32-S3 aus und gibt die serielle Ausgabe zurück, sodass ich Zähler und Timings lesen konnte, ohne die Hardware anzufassen. Das Board bekam nur dann ein neues Image, wenn ein Build es wert war.

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

Die Installation und ein erster Lauf dauern ein paar Minuten: siehe den velxio-cli-Schnellstart.

Eine Stage hinzufügen: wieder das Speicherbudget

Wenn man das Alchemy Lab verlässt, gelangt man zum Castle Entrance (NP3). Ihn hinzuzufügen, ließ den internen RAM um 160 KB überlaufen. Die komprimierten Grafiken, Paletten, Tile-Maps und Sprite-Tabellen der Stage waren als beschreibbar deklariert, also wurden sie beim Boot in den RAM kopiert. Sie als const zu markieren, verschob sie ins Flash. Der Upstream-PC-Port hatte NP3 bereits hinzugefügt, und dieser Commit ließ sich sauber übernehmen.

Zwei weitere Entdeckungen:

Die generierten Dateien werden aus der Disc jedes Nutzers neu erzeugt, also leben die const-Änderungen in einem Skript (esp32/const_stage_tables.py) statt in den generierten Quellen.

Ratschläge für einen ähnlichen Port

Status und nächste Schritte

Ein weiterer Kampf im Alchemy Laboratory, auf dem Handheld

Der Code ist auf GitHub unter davidmonterocrespo24/sotn-decomp (Branch esp32-port). Der Schaltplan und die Teileliste sind im Hardware-Leitfaden. Du brauchst deine eigene Kopie des Spiels; das Repository enthält keine Spieldaten.

Dank an die Mitwirkenden der SOTN-Dekompilierung und an Xeeynamo für psyz. Dieses Projekt baut auf ihrer Arbeit auf.


Teilen auf:
David Montero

Geschrieben von

David Montero

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

GitHub velxio.dev

Ähnliche Artikel


Nächster Artikel
Metal Gear Solid läuft nativ auf dem ESP32-S3