Retour

Castlevania: Symphony of the Night tourne nativement sur l'ESP32-S3

La console portable XIAO ESP32S3 faisant tourner Castlevania: Symphony of the Night dans le Laboratoire d'Alchimie

Dans l’article sur Metal Gear Solid, j’ai présenté la console portable que j’ai construite autour d’un XIAO ESP32S3 Sense. Castlevania: Symphony of the Night (SOTN) est l’autre jeu PlayStation qu’elle fait tourner, et celui qui est arrivé en premier : il tournait à 60 fps sur une carte de développement Waveshare ESP32-S3 avant même que la console portable n’existe, puis a été transféré dessus par la suite.

Cela a été possible pour la même raison que Metal Gear Solid. La décompilation communautaire de SOTN se compile en C portable, donc le jeu peut être compilé pour l’ESP32-S3 au lieu d’être émulé.

Gameplay sur la console portable : regardez-le sur YouTube.

Cet article couvre la structure du portage, comment le jeu tient dans la mémoire du SoC, comment le rastériseur logiciel a atteint 60 fps, les bugs que le matériel d’origine avait masqués, et une console portable que j’ai construite autour d’un Seeed XIAO ESP32S3 Sense.

Pourquoi un portage natif est possible

Émuler une PlayStation sur un ESP32-S3 est hors de portée. Un émulateur doit interpréter un CPU MIPS et un GPU instruction par instruction, ce qui nécessite plusieurs fois la performance dont dispose le SoC.

Le portage est différent. Le projet de décompilation de SOTN a reconstruit le jeu sous forme de code source C qui se compile en exécutable PC. La logique du jeu est du C ordinaire, et la seule chose qui le lie à la console est le SDK de Sony : les appels de bibliothèque qui dessinent les polygones, téléchargent les textures, lisent la manette et jouent les sons. La version PC remplace ce SDK par psyz, une réimplémentation au-dessus de SDL.

Le travail se décompose donc en trois parties :

  1. Compiler le jeu pour Xtensa.
  2. Remplacer le backend PC de psyz par un backend pour le SoC.
  3. Faire tenir le tout.

Le SoC cible est l’ESP32-S3 :

Architecture

Les couches, du jeu jusqu’au matériel :

  1. Jeu : moteur décompilé, code du joueur et des armes, stages
  2. psyz : réimplémentation du SDK avec PSYZ_RENDERER=soft
  3. Couche plateforme (esp32/main) : VSync, compteurs racines, entrées, fichiers FAT, audio, scanout LCD
  4. Matériel ESP32-S3 : cœurs LX7, SRAM, PSRAM, flash, LCD SPI

Les couches du portage et les deux cœurs : le jeu et le rastériseur sur le cœur 0, le scanout LCD et l'audio sur le cœur 1, et l'écran et la microSD partageant SPI2 sur la console portable

Deux décisions de conception ont façonné tout le reste.

La VRAM reste le propre tampon du jeu. La PlayStation dispose de 1 Mo de mémoire vidéo, soit 1024×512 pixels en couleur 16 bits. La décompilation la modélise déjà comme un simple tableau. Le rastériseur dessine directement dedans (en PSRAM), et le LCD est alimenté depuis celle-ci. Il n’y a pas de copies fantômes ni de conversions de format, hormis la conversion finale vers le RGB565 de l’écran.

Les stages sont liés statiquement. Sur PC, chaque zone du château est une DLL chargée à la demande. L’ESP32-S3 n’a pas d’éditeur de liens dynamique, donc chaque zone est compilée dans le firmware, et une petite table associe un nom de stage à sa fonction d’initialisation. Cette décision a causé des problèmes à deux reprises, comme décrit dans « Les bugs que la PlayStation pardonne » et « Ajouter un stage » ci-dessous.

Faire tenir un jeu PlayStation dans 512 Ko

Alucard à côté de l'une des statues du laboratoire

La première édition de liens a échoué avec un dépassement de 3,34 Mo de RAM interne. La majeure partie de ce surplus n’était pas imputable au jeu :

Ce dernier point comporte un piège : certaines données « en lecture seule » sont écrites. Le SDK met à jour les en-têtes de banques sonores sur place lorsqu’une banque est ouverte. Déclarées const, elles résident en flash, et écrire en flash via le cache provoque une faute matérielle (« Dbus write to cache »). Le schéma qui fonctionne consiste à conserver un maître const en flash, à le copier dans un tampon PSRAM au démarrage, et à laisser le jeu écrire dans la copie.

Après 28 cycles d’édition de liens, les chiffres étaient les suivants :

Le rastériseur logiciel, et comment atteindre 60 fps

Les flammes du laboratoire : effets semi-transparents dessinés par le rastériseur logiciel

SOTN est un jeu 2D, et son mélange de primitives est restreint : quads texturés et Gouraud, sprites 16×16 pour les couches de tuiles, rectangles et lignes. Il n’y a ni 3D ni moteur de transformation géométrique, ce qui a rendu un rastériseur logiciel réaliste.

Le rastériseur est exact au bit près par rapport à la version de référence GPU. J’ai comparé les sommes de contrôle des images entre la version PC et la carte après chaque optimisation.

La majeure partie du temps de frame était consacrée aux accès mémoire. Chaque pixel texturé lit un texel et une entrée de palette depuis la PSRAM et écrit un pixel dans la PSRAM. Les optimisations qui ont compté réduisent toutes ce trafic :

ÉtapeRésultat
Pas de bord de triangle : divisions, puis accumulateurs 64 bits, puis 32 bits 16.16Le 64 bits était plus lent sur un cœur 32 bits ; le 16.16 a été retenu
Scanout déplacé sur le cœur 1, alimenté par une notificationLe jeu n’attend jamais le SPI
Cache de palette en RAM interne, boucles de rastérisation en IRAMWarp Room à 31 fps
Chemin rapide pour les couches de tuiles : 4 texels par lecture, paires de pixels en écritures 32 bitsDe 10,4 ms à 6,1 ms par frame
Scanout depuis le tampon que le jeu vient de finir de dessinerAucune copie, une frame de latence en moins
PSRAM et flash de 80 à 120 MHzDe 19,4 ms à 16,4 ms par frame : 60 fps

J’ai aussi essayé un cache de lignes de texture dans le rastériseur de triangles, puis je l’ai retiré. Il n’apportait rien, car GCC avait déjà remonté les chargements.

Le changement consistant à effectuer le scanout depuis le tampon terminé mérite quelques explications. La source évidente pour l’affichage est la zone d’affichage courante du SDK, mais celle-ci a une frame de retard : au VSync, elle désigne le tampon dans lequel le jeu est sur le point de dessiner. Effectuer le scanout depuis celle-ci signifiait que le DMA et le rastériseur se disputaient la même moitié de la VRAM pendant toute la frame. Lire l’origine d’affichage depuis la propre structure de tampon du jeu fait travailler les deux sur des moitiés opposées par construction.

Les bugs que la PlayStation pardonne

La PlayStation n’a pas de protection mémoire. Une lecture errante renvoie ce qui s’y trouve, et une écriture errante atterrit dans une mémoire que souvent personne ne vérifie. La version PC masque aussi la plupart de ces bugs, car ses statiques sont vastes et indulgentes. L’ESP32-S3 dispose d’une MMU, d’une mémoire serrée et de voisins qui comptent, si bien que le portage s’est révélé être un très bon moyen de trouver des bugs latents.

Combat dans le Laboratoire d'Alchimie sur la console portable

Comment je les ai trouvés

Une trace de panique nomme la victime, pas le coupable. L’outil qui a fonctionné était OpenOCD via l’USB-JTAG intégré au SoC, conjointement avec GDB :

  1. Réinitialiser et mettre en pause le SoC via OpenOCD.
  2. Placer un point d’arrêt sur le gestionnaire de panique.
  3. Quand il est atteint, décoder le vrai PC depuis la trame de panique.
  4. Toujours décoder par rapport à l’ELF exact qui a été flashé, car les adresses changent à chaque recompilation.

Animation de palette hors limites

Le Labo d’Alchimie demande l’animation de palette (tileset & 0xFF) + 0x7FFF | 0x4000, ce qui indexe l’entrée 2 de la table de palettes du stage. La table n’a qu’une entrée. La lecture hors limites a atterri sur la table de banques de sprites adjacente, qui a ensuite été enregistrée comme descripteur d’animation de palette et écrite à chaque image.

Le correctif rejette les descripteurs dont l’étendue dépasse le tampon de palette. Le même comportement indéfini existe en amont ; le PC l’absorbe dans un grand BSS.

Deux stages, un seul HitDetection

Avec les stages liés statiquement, 122 symboles globaux étaient définis dans plus d’un stage. Les en-têtes partagés implémentent des choses comme la collision et les mises à jour d’entités, et sur la PlayStation un seul stage était jamais chargé. Les archives statiques laissent l’éditeur de liens résoudre silencieusement chaque nom vers une seule définition. Le Labo d’Alchimie exécutait le HitDetection de la Salle de Téléportation contre ses propres tables d’entités, et la corruption apparaissait une couche plus tard.

Le correctif est une liste générée de chaque symbole partagé par deux stages, renommé par stage avec des définitions -D.

Des salles qui mutent leur propre carte

Les portes et les murs destructibles écrivent dans la carte de tuiles de la salle. Avec les cartes générées en flash, la première porte a fait planter le jeu. Le correctif est un point unique où la couche de premier plan de la salle active est copiée dans un tampon PSRAM inscriptible.

Le nom de l’ennemi

La boîte du nom de l'ennemi en bas de l'écran, dessinée par le même code BottomCornerText qui débordait autrefois la pile

Avec la relique Parchemin de Fée, le jeu affiche le nom de l’ennemi que vous frappez. BottomCornerText parcourt la chaîne à la recherche du terminateur FF 00 de la PlayStation. Les chaînes PC sont de simples chaînes C, donc le parcours dépassait la fin et écrasait un tampon de pile de 64 octets. Le symptôme était « ça se fige dès que je touche un ennemi ». Le correctif borne le parcours.

Une course qui n’existait pas sur la PlayStation

L’audio tourne sur le second cœur, et le tirage audio faisait avancer les compteurs racines. Ces compteurs déclenchent le gestionnaire VSync du jeu, les téléversements de textures et les callbacks de la file GPU. Sur la PlayStation, ce sont des interruptions sur le même CPU. Ici, ils s’exécutaient concurremment avec le jeu sur l’autre cœur.

Le correctif accumule les ticks de compteur sur le cœur 1 et déclenche les gestionnaires sur le thread du jeu.

Une entrée qui n’arrivait jamais

Le rapport était « je ne peux pas sauter ». Trois bugs étaient empilés :

La console portable : mise en route matérielle sur un XIAO ESP32S3 Sense

La console portable terminée vue de dessus : écran ILI9341, stick de nacelle de drone, boutons et le XIAO sur plaque à pastilles

L'arrière de la console portable : câblage point à point sur la plaque à pastilles et le slot SD de l'écran (inutilisé). Le module caméra du XIAO, retiré, repose à côté

L'avant de la console portable à côté du module caméra du XIAO, que le portage n'utilise pas et qui a été retiré

L’objectif était une console portable. C’est le même matériel qui a ensuite fait tourner Metal Gear Solid : SOTN a été le premier jeu dessus. Les pièces :

Le XIAO dispose de onze broches utilisables. L’écran en nécessite cinq (SCK, MOSI, MISO, CS, DC) plus une ligne de reset, et le stick nécessite deux broches ADC. Il en reste trois pour huit boutons.

Six des boutons partagent une broche ADC via une échelle de résistances : une résistance de tirage de 10k et, par bouton, une résistance vers la masse (0 Ω, 2k2, 4k7, 10k, 22k, 47k). Chaque bouton produit une tension différente. La limitation est que deux boutons maintenus ensemble se lisent comme le plus bas. La paire que chaque jeu maintient ensemble (attaque et saut dans SOTN) reçoit les deux broches dédiées restantes.

Le schéma de câblage complet, la liste des pièces et la carte des boutons se trouvent dans le guide matériel du dépôt.

Câblage de la console portable, dessiné avec les illustrations de composants de Velxio : XIAO ESP32S3 Sense, écran ILI9341, stick analogique, échelle de résistances à six boutons et deux boutons directs

La mise en route a été sa propre liste de leçons. Avant de toucher au jeu, j’ai écrit un firmware de test séparé qui dessine des barres de couleur et affiche chaque bouton et le stick à l’écran :

La console portable — écran, entrées et un crash attrapé par un watchpoint

Tout sur la carte SD

8 Mo de flash ne peuvent pas contenir une application de 3 Mo à côté de 3,7 Mo de données de jeu, donc sur la console portable toutes les données viennent de la carte microSD. Cette carte partage le bus SPI avec l’écran, ce qui a plus tard provoqué son propre crash (voir « Le bus SPI partagé » ci-dessous).

Deux pièges propres à cette carte

GPIO43 est à la fois la broche data/command de l’écran et le TX de l’UART0. Le code Waveshare installait le pilote UART pour sa console série. Ce faisant, la broche repasse silencieusement à l’UART, et l’écran cesse de distinguer les commandes des pixels. La console portable n’utilise l’USB natif que pour sa console.

printf sur l’USB natif bloque quand personne ne lit. Avec un PC branché, on ne le remarque jamais. Débranché, le tampon d’émission se remplit en quelques secondes et le jeu s’arrête à l’intérieur d’un printf, sur un appareil censé fonctionner débranché. Toute la sortie, y compris la journalisation d’ESP-IDF elle-même, passe désormais par un wrapper qui écrit avec un délai d’attente nul et abandonne ce que personne ne lit.

Scintillement, puis rectangles glissants

La première compilation scintillait. J’ai supposé que l’horloge SPI était trop lente et je l’ai augmentée de 40 à 80 MHz. Cela a un peu aidé, mais en instrumentant le scanout, j’ai vu ce qui se passait réellement :

Chronologie — le jeu dessine une image toutes les 16,7 ms dans les tampons A et B tandis que l'écran met environ 24 ms à en recevoir une, donc le jeu redessine un tampon que le DMA est encore en train d'envoyer

C’était un problème de cohérence des tampons. Le correctif était le chemin de copie que le portage Waveshare avait conservé comme filet de sécurité — copier l’image terminée, puis envoyer la copie. Ma première version de ce mécanisme avait deux bugs :

Même après ces deux corrections, les rectangles persistaient. Ramener l’horloge SPI à 40 MHz les a fait disparaître — les fils de liaison ne tenaient pas les 80 MHz, et un décalage d’un octet décale toute une bande de 8 lignes. L’horloge SPI du S3 divise une source de 80 MHz, donc les seules options sont 80 et 40 MHz, sans rien entre les deux.

Les fils étant le goulot d’étranglement, l’option restante était d’envoyer moins de données. Chaque bande de 8 lignes est empreintée au moment de sa conversion en RGB565, et les bandes identiques à ce que l’écran affiche déjà sont ignorées. Les images abandonnées sont passées d’environ 40 % à 6 %. Cela donne à peu près 55 fps à l’écran quand la caméra est immobile, tandis que la logique du jeu reste à pleine vitesse.

Le crash attrapé par un watchpoint matériel

Le rapport suivant était « j’ai appuyé sur START et ça a redémarré ». Il y en avait eu un autre avant — « du texte est apparu, puis l’écran est devenu blanc, puis ça a redémarré ».

La panique se produisait dans ClearOTag, appelée depuis la boucle principale avec un pointeur d’ordering table de 0x4b8. La boucle principale avance avec g_CurrentBuffer = g_CurrentBuffer->next, donc en remontant, quelque chose avait écrit 0x44 dans le lien next de l’un des deux tampons GPU.

L’ESP32-S3 dispose de deux watchpoints matériels. Je les ai armés tous les deux, un sur le champ next de chaque tampon, deux secondes après le démarrage, bien après la seule écriture légitime. Appuyer sur START a arrêté le CPU sur le store du coupable :

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

Le menu pause prend le prochain sprite libre avec &g_CurrentBuffer->sprite[g_GpuUsage.sp] et ne vérifie jamais la borne. sprite[] est le dernier champ d’un tampon GPU, et le second tampon commence juste après, en débutant par son lien next. Dessiner le texte des statistiques a dépassé les 512 sprites, et AddPrim a écrit un en-tête de primitive par-dessus le lien. Le même jeu vérifie g_GpuUsage.sp < MAX_SPRT_COUNT ailleurs, mais le menu ne le faisait pas. Deux lignes ont suffi à corriger cela.

Les deux tampons GPU sont adjacents en mémoire — le 513e sprite du menu pause atterrit sur le lien next du tampon suivant, ce qu'un watchpoint matériel a attrapé

Le bus SPI partagé

Juste après, un crash différent est apparu lors des chargements de niveau — une assertion dans le pilote SPI, running_cmd == 0. La carte démarrait une commande alors qu’un transfert DMA de l’écran était encore sur le fil. Sur la carte Waveshare, la carte ne diffusait que la musique optionnelle ; sur la console portable, elle transporte tout.

Le correctif est un mutex :

Instrumenter sans se tromper soi-même

Deux leçons de cette période :

Exécuter chaque compilation sans flasher la carte

Après plusieurs heures d’essais et d’erreurs, flasher la carte après chaque changement était l’étape la plus lente de la boucle — écrire l’image en USB, attendre le redémarrage, reconnecter le port série sans redémarrer à nouveau la carte, lire le journal, recommencer. Donc, pour exécuter et vérifier chaque compilation, j’ai utilisé velxio-cli, le client en ligne de commande de Velxio. Il prend le même .bin que ma chaîne d’outils produisait, l’exécute sur l’ESP32-S3 simulé de Velxio pendant une durée simulée fixe et renvoie la sortie série, ce qui me permettait de lire les compteurs et les timings sans toucher au matériel. La carte ne recevait une nouvelle image que quand une compilation le méritait.

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

L’installer et faire une première exécution prend quelques minutes — voir le démarrage rapide de velxio-cli.

Ajouter un niveau — le budget mémoire, encore

En sortant du Laboratoire d’Alchimie, on arrive à l’Entrée du Château (NP3). L’ajouter a fait déborder la RAM interne de 160 Ko. Les graphismes compressés, les palettes, les tile maps et les tables de sprites du niveau étaient déclarés modifiables, donc ils étaient copiés en RAM au démarrage. Les marquer const les a déplacés en flash. Le portage PC amont avait déjà ajouté NP3, et ce commit s’est transposé proprement.

Deux autres découvertes :

Les fichiers générés sont régénérés à partir du disque de chaque utilisateur, donc les changements const vivent dans un script (esp32/const_stage_tables.py) plutôt que dans les sources générées.

Conseils pour un portage similaire

État actuel et prochaines étapes

Un autre combat dans le Laboratoire d'Alchimie, sur la console portable

Le code est sur GitHub à davidmonterocrespo24/sotn-decomp (branche esp32-port). Le schéma de câblage et la liste des composants se trouvent dans le guide matériel. Vous avez besoin de votre propre copie du jeu ; le dépôt ne contient aucune donnée de jeu.

Merci aux contributeurs de la décompilation de SOTN, et à Xeeynamo pour psyz. Ce projet est bâti sur leur travail.


Partager sur :
David Montero

Écrit par

David Montero

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

GitHub velxio.dev

Articles liés


Article suivant
Metal Gear Solid tourne nativement sur l'ESP32-S3