
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:
- Das Spiel für Xtensa kompilieren.
- psyz’ PC-Backend durch eines für den SoC ersetzen.
- Alles passend machen.
Der Ziel-SoC ist der ESP32-S3:
- Zwei Xtensa LX7-Kerne mit 240 MHz.
- 512 KB interner SRAM.
- 8 MB octal PSRAM (extern, deutlich langsamer).
- 16 MB Flash auf dem ersten Board und 8 MB auf dem Handheld.
- Keine GPU.
Architektur
Die Schichten, vom Spiel bis hinunter zur Hardware:
- Spiel: dekompilierte Engine, Spieler- und Waffencode, Stages
- psyz: SDK-Reimplementierung mit PSYZ_RENDERER=soft
- Plattformschicht (esp32/main): VSync, Root-Counter, Eingabe, FAT-Dateien, Audio, LCD-Scanout
- ESP32-S3-Hardware: LX7-Kerne, SRAM, PSRAM, Flash, SPI-LCD
- Spiel: die dekompilierte Engine (
src/dra), der Spieler- und Waffencode und jeder Schlossbereich („Stage”). Diese sind bis auf Bugfixes unverändert. - psyz: die SDK-Reimplementierung. Ich habe einen dritten Renderer hinzugefügt,
PSYZ_RENDERER=soft. Er ist ein Packet-Parser plus ein Rasterizer-Kern ohne SDL-Abhängigkeit, sodass dieselbe Datei auf Windows (zum Testen gegen die GPU-Referenz) und auf dem SoC kompiliert. - Plattformschicht (
esp32/main): VSync-Taktung mit 59,94 Hz, die Root-Counter, die den Sound-Sequencer antreiben, Controller-Eingabe, Dateizugriff über FAT, Audio-Mixing auf dem zweiten Kern und der LCD-Scanout.
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

Der erste Link schlug um 3,34 MB internen RAM fehl. Das meiste davon war nicht die Schuld des Spiels:
g_TileDefDataPoolwar alsTileDefinition[0x40][4][0x1000]deklariert. Bei 32-Bit-Zeigern sind das 16 bis 32 MB, um etwa 1 MB Daten zu halten. Die Umtypisierung auf Bytes behob den größten Posten.- Die Sound-Bibliothek allokierte ein 512 KB großes Array auf dem Stack. Das funktioniert auf einem PC; auf einem Mikrocontroller stürzt es sofort ab.
- Große Puffer, die nur gelegentlich berührt werden, wanderten mit
EXT_RAM_BSS_ATTRins PSRAM. - Schreibgeschützte Tabellen wurden als
constmarkiert, was sie ins Flash legt.
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:
- 207 KB initialisierte Daten im internen RAM.
- 43 KB null-initialisierte Daten im internen RAM.
- 55 KB IRAM-Code.
- 3 MB Statics im PSRAM.
- Etwa 100 KB freier interner Heap zur Laufzeit.
Der Software-Rasterizer und der Weg zu 60 fps

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:
| Schritt | Ergebnis |
|---|---|
| Triangle-Edge-Stepping: Divisionen, dann 64-Bit-Akkumulatoren, dann 32-Bit 16.16 | 64-Bit war langsamer auf einem 32-Bit-Kern; 16.16 wurde ausgeliefert |
| Scanout auf Kern 1 verschoben, gespeist durch ein Notify | Das Spiel wartet nie auf SPI |
| Paletten-Cache im internen RAM, Raster-Loops im IRAM | Warp Room bei 31 fps |
| Tile-Layer-Fast-Path: 4 Texel pro Lesezugriff, Pixelpaare als 32-Bit-Stores | 10,4 ms auf 6,1 ms pro Frame |
| Scanout aus dem Puffer, den das Spiel gerade fertig gezeichnet hat | Keine Kopie, ein Frame weniger Latenz |
| PSRAM und Flash von 80 auf 120 MHz | 19,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.

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:
- Den SoC über OpenOCD zurücksetzen und anhalten.
- Einen Breakpoint auf den Panic-Handler setzen.
- Wenn er auslöst, den echten PC aus dem Panic-Frame dekodieren.
- 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

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 USB-Seriell-Port verwirft eingehende Bytes, solange DTR low ist, und mein Keyboard-Skript hielt ihn low, um ein Zurücksetzen des Boards zu vermeiden.
- Die Konsole war für den UART konfiguriert, während das Kabel an nativem USB hing.
- Ein nicht verdrahteter Button-Pin las sich als dauerhaft gedrückt, was CROSS für immer unten hielt.
Der Handheld: Hardware-Bring-up auf einem XIAO ESP32S3 Sense



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:
- Seeed XIAO ESP32S3 Sense: derselbe ESP32-S3 SoC mit 8 MB PSRAM, 8 MB Flash und einem microSD-Slot auf seinem Erweiterungsboard.
- ILI9341 320×240 SPI-Panel: die native Auflösung der PlayStation, also ist keine Skalierung nötig.
- Ein Analogstick aus einem Drohnen-Controller.
- Acht Buttons.
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.
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:
- Das Panel war weiß und wurde dunkler, wenn ich Buttons drückte. Es hatte keine Masseleitung, also versorgte es sich selbst über die Schutzioden seiner Datenpins. Das Hinzufügen von GND behob es sofort.
- Das Panel war ein ILI9341 statt des geplanten ILI9488. Es ist ein anderer Controller mit einem anderen Pixelformat (RGB565 über SPI statt RGB666). Sein Treiber ist nicht in ESP-IDF eingebaut und kommt aus der Komponenten-Registry.
- Eine echte Reset-Leitung ist wichtig. Mit auf high gelegtem RESET und nur einem Software-Reset startete das Panel nicht zuverlässig.
- Vierbeinige Buttons: Zwei Beine auf derselben Seite sind intern bereits verbunden. Wenn man diese verdrahtet, ist der Button immer gedrückt. Diagonale Beine verwenden.
- Der Stick driftete von selbst. Ein Drohnen-Gimbal ruht dort, wo seine Federn ihn hinbringen (2317 von 4095 bei meinem, wo man 2048 annehmen könnte), und der ADC ist verrauscht. Der Fix:
- Die Mitte beim Boot kalibrieren.
- Den Verfahrweg jeder Achse im Betrieb erlernen.
- 8 Samples mitteln.
- Eine Dead Zone von 60 Counts anwenden, das Vierfache des im Ruhezustand gemessenen Rauschens.
- Die Y-Achse war zweimal invertiert. Die Shakedown-Firmware negierte Y, damit “oben bewegt den Punkt nach oben” auf einem Bildschirm gilt, dessen Y nach unten wächst. Das Spiel nimmt Richtungsbits, wobei oben einfach oben ist. Die Übernahme der Bildschirmkonvention invertierte es erneut.
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:
- Ein Frame brauchte etwa 24 ms zum Senden.
- Das Spiel produzierte alle 16,7 ms einen.
- Also überholte das Spiel das Panel: Es tauschte die Puffer und begann, den neu zu zeichnen, den die DMA noch übertrug.
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:
- Sie kopierte nur die Zeilen des aktuellen Clip-Rechtecks des Rasterizers, das ein Effekt auf ein paar Zeilen schrumpfen kann. Der Bildschirm fror ein, während das Spiel weiterlief.
- Mit zwei Kopierpuffern konnte sie den überschreiben, der noch gesendet wurde. In einer horizontal scrollenden Szene zeigt sich das als seitlich gleitende Rechtecke.
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.
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:
- Das Panel hält ihn für einen Frame und leert seine laufende DMA, bevor es ihn freigibt.
- Die Karte nimmt ihn um jedes Kommando herum, indem sie den
do_transaction-Hook des Treibers umhüllt.
Instrumentieren, ohne sich selbst zu täuschen
Zwei Lektionen aus diesem Abschnitt:
- Mein serieller Listener startete das Board neu. Den Port auf die Standardweise zu öffnen, setzt DTR/RTS, was beim nativen USB des S3 Reset und Boot-Select bedeutet. Jedes Mal, wenn ich mich verband, um einen eingefrorenen Bildschirm zu untersuchen, startete ich das Board neu und las ein gesundes Log. Die Lösung ist, das Port-Objekt zu erzeugen, beide Leitungen abzusenken und es dann zu öffnen.
- Miss die gesamte Operation. Meine erste Scanout-Messung trennte „Warten” und „Konvertieren”, ließ aber den Draw-Call selbst aus, der blockiert, wenn die SPI-Warteschlange voll ist. Sie meldete 5 ms für einen Frame, der 24 ms brauchte.
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:
- Richters Sprites (die Figur aus dem Prolog) waren 85 KB beschreibbarer Daten im RAM, die im normalen Spielverlauf nie genutzt wurden. Sie ebenfalls als
constzu markieren, bedeutete, dass die interne RAM-Nutzung nach dem Hinzufügen von NP3 niedriger war als zuvor. - Der Linker meldete 16 doppelte Symbole zwischen NP3 und den anderen Stages. Es waren 31. Der Vergleich der Symboltabellen mit
nmfand die anderen 15, darunterEntitySlograundEntityGaibon: Boss-Code, den NP3 stillschweigend vom Alchemy Lab geborgt hätte.
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
- Wähle eine Dekompilierung, die bereits ein portables Target baut. Das ist es, was einen nativen Port möglich macht, wo ein Emulator es nicht ist.
- Bring dem Board bei, dir zu sagen, was falsch ist. Nutze Zähler, in ihre Bestandteile aufgeteilte Timings und Frame-Prüfsummen. Die meisten meiner falschen Hypothesen wurden durch eine Zahl widerlegt.
- Ein Backtrace benennt das Opfer. Bei Speicherkorruption benennt ein Hardware-Watchpoint auf die korrumpierte Adresse den Übeltäter.
- Speicher ist das Performance-Budget. Auf diesem SoC schlagen weniger PSRAM-Zugriffe jede clevere Arithmetik.
- Die Toleranz der Originalhardware verdeckt Bugs. Erwarte, einige zu finden.
Status und nächste Schritte

- Waveshare-Board: Alchemy Laboratory mit 60 fps.
- Handheld: Spiellogik mit voller Geschwindigkeit, Bildschirm bei etwa 55 fps, wenn die Kamera stillsteht.
- Verbundene Stages: Alchemy Lab, Warp Room und das Titel-/Speichermenü. Castle Entrance ist verbunden und wartet auf seinen ersten Test auf dem Handheld.
- Sound: Effekte funktionieren, und XA-Musik wird abgespielt, wenn das Disc-Image auf der Karte liegt.
- Als Nächstes: weitere Stages (das Speicherbudget hat jetzt Raum), und ein gelöteter Aufbau für den Handheld, um erneut 80 MHz SPI zu testen.
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.