DOOM ! DOOOOOOOM !

Je suis fasciné par ce jeu, son gameplay, son histoire, sa technique … Et il se passe encore plein de choses autour de ce vénérable, culte et révolutionnaire titre sorti en 1993, dont le code source du moteur a été publié en open-source en 1997, et dont la communauté est encore très active.

La publication en open-source a ouvert la voie à une multitude d’évolutions et d’expérimentations, allant de la simple adaptation ou amélioration au portage sur à peu près n’importe quoi :

If it exists and has a microprocessor and a screen, it can run Doom !

Mon moi du passé lointain (2 mois, une éternité …) avait laissé à l’intention de mon moi du passé récent (2 semaines) un onglet ouvert sur le navigateur : stm32doomDoom (plus exactement Chocolate Doom) pour carte d’évaluation STM32F429. Carte dont je dispose (qui n’est pas chère d’ailleurs, on la touche à ~\$30) … Ho ho ho … :DJ’ai testé : ça marche, c’est … dingue ! Et tellement cool :P

Limitations

Par contre il y a quelques limitations (le portage a été fait par un mec tout seul en 2015), que je suis bien motivé pour essayer de faire sauter ou contourner:

  • Pas de son.
  • Pas de musique.
  • Contrôles de merde. Le tactile, sur du FPS, comment dire … Il me semble impératif de faire honneur à ce jeu en permettant d’y jouer avec les contrôleurs recommandés par la PC Gaming Master Race : le clavier et la souris !
  • doom1.wad only. L’intégralité du WAD est chargée dans la RAM externe de la carte dévaluation, qui fait 8Mo. De fait, cela limite la taille du WAD à 8Mo, et dans les WAD “courants”, il n’y a que celui de l’épisode 1 shareware (doom1.wad donc) qui rentre (il fait ~4Mo).
  • Pas de sélection du WAD au démarrage, le nom du WAD à charger est écrit en dur dans le code.

Pour le son, il faut dire qu’il n’y a pas de sortie audio sur cette carte, mais ça peut s’arranger, il suffit de faire du PWM ou du PDM sur une sortie, voire du DAC (les DAC sur les STM32 tournent à 50kHz, pour pouvoir générer du 48kHz). Par contre, il faut faire un driver pour ça. Idem pour la musique, avec une contrainte supplémentaire : la musique est en MIDI, cela va de soi. Ce qui sous-entend qu’il faut interpréter les fichiers MIDI, et surtout synthétiser les sons des instruments :o Là on est pas dans le simple …

Pour le contrôleur il y a deux approches : ajouter le support clavier souris, ou faire un contrôleur DIY spécifique. Pour le support du clavier / souris, il n’y a qu’un seul port USB (micro) en host sur la carte (et de toutes façons il n’y a qu’un seul USB possible en host sur cette gamme de STM32, que je sache). Donc il faut de toutes façons multiplexer, donc utiliser un HUB. Or, le support des HUB n’est pas natif dans les libs / stack USB “standard”. En tous cas pas dans les couches ST, utilisées ici. Certaines stack implémentent le support des HUB, mais la grande majorité sont des libs non-free et payantes. La seule free que j’ai vu c’est emCraft. Je n’ai aucune idée de ce que c’est et de ce que ça vaut. Par contre, après avoir cherché un peu sur les forums, il semblerait qu’ajouter le support des HUBs USB soit loin d’être complexe, et que ça prendrait ~400 lignes de code, ce qui est non-négligeable, mais tout à fait raisonnable.

Pour un contrôleur DIY, je m’inspirerait bien du contrôleur que Roul et 3ds ont fait pour la brodeuse :)Par contre pour la souris … je pense qu’il faut une vraie souris.Pour l’interface, soit on fait un contrôleur USB qu’on multiplexe (cf point précédent) soit on le connecte à des GPIO ou un bus de com du MCU.

Pour la RAM … Vaste sujet. plusieurs options s’offrent à nous.- Porter le projet sur une carte qui a plus de RAM (en Discovery yen a pas, même sur les cartes d’évaluation “chères”)- Ajouter une RAM supplémentaire (SPI ?)- Changer la gestion du chargement des datas

Ce dernier point me semble plus intéressant. Il faut noter que 8Mo de RAM, en 1993 (‘back in the days’ comme on dit), c’était assez standard dans les PCs. Par contre, Vanilla Doom (et donc Chocolate Doom) sont des sourceports plus récent, dont l’objectif est de reproduire l’expérience de jeu la plus proche de l’original, tout en rendant l’utilisation simple sur les OS récents. Donc, comme beaucoup de sourceports, ils tirent parti des ressources disponibles sur les PCs récents, et entre autre la RAM illimitée (en comparaison de 93). Donc, à mon avis, le fait qu’il charge l’intégralité du WAD dans la RAM est un choix de design du sourceport utilisé, choix fait par commodité pour les PCs desktop, mais contraignant ici, vu que l’on est sur une plateforme donc la quantité de mémoire disponible est similaire aux PCs de 93. Par contre niveau puissance de calcul, on est à 180MHz, looooiiin devant les 486SX33 de l’époque :P et je ne parle pas des temps d’accès, vitesse des bus de com’ … Donc je pense qu’il serait pertinent de charger dynamiquement les datas dans la RAM, pour limiter le taux d’occupation à un instant t. Et je suis persuadé que c’est ce qu’ils font dans le moteur d’origine, donc il va falloir que je checke ça. Par exemple, garder en mémoire l’intégralité des niveaux n’a pas de sens, vu qu’on ne joue que dans un seul niveau à la fois. Donc quand on finit un niveau il faudrait vider les données du niveau qu’on vient de finir, et mettre la place les données du niveau suivant avant de le lancer.

(A noter que le portage sur d’autres cartes peut avoir un autre intérêt : un écran plus grand :) par exemple sur celle-ci ou celle-là, qui peuvent aussi bénéficier d’autres périphériques sympathiques, genre un codec audio …)

Pour aller plus loin sur le sujet des performances mémoire, je me demande comment étaient chargés les assets graphiques et sonores dans le jeu d’origine, et s’il y aurait un intérêt à faire un chargement dynamique là aussi. Pour les sons (sfx), je pense que ça a clairement un intérêt, parce que même s’ils sont à un samplerate assez faible, ça serait bien d’éviter de les avoir en permanence en RAM. Pour les assets graphiques je ne sais pas trop. On pourrait se dire qu’au chargement du niveau on checke quels sont les textures et sprite requises, et qu’on ne charge que celles-là, mais je doute que ça soit vraiment faisable sans modifier radicalement le code d’origine, et je ne suis pas persuadé qu’on gagne tant que ça, car ça suppose que les niveaux utilisent peu d’assets. Et quand à les charger dynamiquement en temps-réel, c’est-à-dire au fur et à mesure qu’on en a besoin pour afficher un frame … Hum, ça me semble overkill. Mais ptet ça a du sens ? En tous cas ça ferait sauter quasiment toutes les limitations, car on ne chargerait que ce qu’on a besoin pour la frame à afficher en cours (à moins d’avoir un design de niveau qui oblige à afficher plein d’assets en même temps sur la même image, c’est peu probable - quoique avec certains slaughterWADs …). Bon, là on est dans le supplice des diptères.

Le vrai gros sujet, à mon avis, c’est la musique. Parce qu’on parle quand-même de faire du General MIDI sur un STM32, donc un synthétiseur polyphonique multi-timbral. Ce n’est absolument pas trivial, c’est même un projet à lui tout-seul 8|Mais … Ben ça m’intéresse quand-même, parce que la synthèse ça me parle :coeur: Mais c’est vraiment un gros truc.l’un des sous-sujets qui va avec c’est de savoir si on fait de la synthèse algorithmique (ce qui va demander beaucoup de temps de dev, à moins de trouver des algos déjà-faits) soit on fait des wavetables. Dans le deuxième cas (plus facile à mettre en œuvre à mon avis), il va falloir stocker les wavetable quelque part, et donc éviter de remplir la RAM avec, donc il y a de l’archi à faire.

Oublions un instant la musique. Si on arrive à faire sauter la limitation sur la taille du WAD, ça voudra dire qu’on pourra charger d’autres fichiers que doom1.wad. De base, Chocolate Doom, comme beaucoup de sourceports, va chercher les IWADs “officiels” connus : Doom shareware, Doom retail, Doom 2, Heretic, FreeDoom … On pourrait se limiter à ça, mais j’aimerais bien pouvoir charger des WADs custom (yen a quand-même plein de bien qui sont compatibles avec les sourceports vanilla), mais pour ça il faut pouvoir les sélectionner. Par ordre croissant de confort d’utilisation:

  • Écrire le nom du fichier dans le code en dur et re-compiler à chaque fois :erk:
  • Écrire le nom dans un fichier de config dans la clé USB (c’est comme ça que marchent la plupart des sourceports actuels)
  • Faire un explorateur de fichiers

La troisième solution est bien Roxxor mais bien user-friendly, et étonnamment je n’ai pas trouvé d’exemple d’explorateur de fichier “standalone” pour STM32. Alors soit j’ai mal cherché, soit c’est considéré comme inutile ou overkill. En tous cas ça me surprend car il y en a un dans le programme d’exemple qui est chargé d’origine dans la carte. Bon, ben si je me mets sur ce sujet je commencerais par dépiauter cet exemple.

Gros gros gros sujet. J’ai vraiment très envie de bosser dessus. Et j’ai vraiment très pas le temps.Fuuuuuuu …

Quelques liens intéressants

D’autres ports sur d’autres cartes ST, pas forcément documentés:

Plutôt qu’alourdir cet article, je vais faire un article dédié sur chaque partie.

Support HUB USB

Voir l’article dédié (à venir)

Support clavier / souris

Normalement, une fois qu’on a le point précédent géré, il faut gérer les périphériques HID. Ça, à priori, les couches ST le supportent. C’est parfait, ça me va.

Maintenant il faut relier ça aux entrées du jeu. La récupération des inputs est faite dans … i_video.c, dans la fonction I_GetEvent. Dans Chocolate Doom aussi, en utilisant les routines de gestion d’évenements de SDL. Donc il faut faire un driver HID USB pour récupérer les inputs du clavier et de la souris avec des fonctions read, et I_GetEvent va poller et remplir la pile d’events avec D_PostEvent. Dans le code de floppes, il y a un gros if / else if qui va check le bouton (tir) et le touchscreen. Il n’y a qu’un appel de D_PostEvent pour le bouton de tir et un pour le gros if du touchscreen, ce qui signifie qu’on ne peut, sur une frame, avoir que deux inputs simultanés, dont un ne peut être que l’appui sur le bouton de tir. Bien entendu, il faudra faire mieux que ça et gérer des inputs simultanés, un peu comme le fait Chocolate Doom avec un while qui dépile la pile d’évènements SDL pour la remplir dans la pile de Doom. Il faut que je fasse des tests pour voir si ça vaut le coup de faire une pile d’évènements aussi ici. Je n’en suis pas persuadé, en tous cas si jamais je le fais ça sera dans un deuxième temps. Et ça dépendra de comment sont gérés le clavier et la souris par le driver HID.

Chargement dynamique des données

Après réflexion, il ne me semble pas pertinent de charger dynamiquement les assets graphiques. On ne peut pas trop prévoir à l’avance lesquels sont requis à un instant T, ce qui forcerait à faire des appels intempestifs au FS, ou alors on ne chargerait que les assets requis par le niveau, ce qui ne marcherait que si les niveaux utilisent une quantité limitée de textures. Peu probable et trop limitant. De plus, vu la différence notable de taille entre le WAD du shareware et du retail, et si on suppose que les assets sont globalement les mêmes (monstres yen a que quelques uns en plus, armes une seul en plus, textures … ouais, pas mal quand-même) on peut supposer que ce sont les niveaux qui représentent la plus grosse taille. De plus, la qualité de données requise pour une géométrie de niveau est parfaitement déterministe, donc plus facile à gérer. Charger dynamiquement les niveaux uniquement pourrait permettre de gagner de la RAM, le reste me semble apporter plus de problème qu’il n’en résout.

Pour la musique, on peut quand-même imaginer ne charger que ce qui est utilisé par le niveau en cours (il n’y a qu’un seul morceau par niveau) pourrait sans doute permettre de gagner un peu, mais je pense que c’est du nice to have.

Mais alors, comment met-on ça en place ?

Regardons plus en détail le contenu d’un fichier WAD. Difficile de trouver des infos ou des outils qui donnent des infos détaillées sur les dimensions des différents éléments, donc il va falloir les extraire à la main. Prenons le shareware comme exemple (doom1.wad) et on va l’ouvrir avec un éditeur hexadécimal (j’utilise le plugin Hex edit de notepad++).

Le fichier commence par un header (wadinfo_t) qui contient les valeurs suivante en hexa:

49 57 41 44 f0 04 00 00 b4 b7 3f 00

Les 4 premiers octets sont une chaîne de caractères, qui indique si c’est un IWAD ou un PWAD. On sort notre plus belle table ASCII et on y voit le texte 0x49 57 41 44 = “IWAD”, ce qui est logique. Les 4 octets suivants donnent le nombre total de lumps. Attention, les chaînes de caractères sont écrites en big-endian (on lit les lettres dans l’ordre où elles apparaissent dans la mémoire), par contre les entiers sont en little-endian. Donc ici pour lire 0xF0 04 00 00 il faut inverser l’ordre des octets, ce qui donne 0x00 00 04 F0, 1264 en décimal, c’est le nombre de lumps (objet de base dans un WAD). Les 4 octets qui suivent donnent l’adresse où se trouvent les structures qui décrivent les lumps en question, là aussi en little-endian, donc : 0x003FB7B4, c’est l’adresse où il faut aller chercher les infos sur les lumps dans le fichier. Entre les deux, c’est tout le contenu des lumps en question. Eh bien allons donc à cette fameuse adresse, tout au bout du fichier. On y trouve ceci:

""

Chaque lump a une structure le décrivant (filelump_t), et cette structure contient 4 octets pour la position du lump, 4 octets pour la taille et 8 octets pour le nom. sachant que les noms ne sont pas uniques, ils servent surtout à identifier quel type de données contient le lump. Il y a un paquet de données “générales” (palette, endoom …), puis les données des niveaux. Chaque niveau commence par un lump “virtuel” de taille 0 qui donne le nom du niveau (ExMy), puis on trouve un exemplaire de chaque type de donnée requis pour pouvoir construire le niveau (LINEDEFS, NODES, SEGS …). Si on prend E1M1, j’ai surligné son lump de titre. Les 4 octets précéedents sont la taille (ici 0, il est virtuel), puis les 4 précédents la position, toujours en litle-endian, ici 0x000107AC. Puis les déclarations des lumps du niveau : THINGS, LINEDEFS, etc, jusqu’à un lump de taille 0 qui s’appelle E1M2, qui délimite donc le contenu du niveau suivant. Donc, si je veux charger le niveau E1M1, je n’ai besoin de charger que les données comprises entre ces deux lumps virtuels.

(Au passage : le plugin Hex-edit de Notepad++ est une vraie purge : les options d’affichage ne changent rien, et quand on copie-colle, il remplace les 0x00 par des 0x20 …)

Si on extrait tous les éléments de E1M1, on trouve les lumps suivants (Nom / adresse / taille en octets):

  • E1M1 / 0x000107AC / 0
  • THINGS / 0x000107AC / 1380
  • LINEDEFS / 0x00010D10 / 6650
  • SIDEDEF / 0x0001270C / 19440
  • VERTEXES / 0x000172FC / 1868
  • SEGS / 0x00017A48 / 8784
  • SSECTORS / 0x00019C98 / 948
  • NODES / 0x0001A04C / 6608
  • SECTORS / 0x0001BA1C / 2210
  • REJECT / 0x0001C2C0 / 904
  • BLOCKMAP / 0x0001C648 / 6922

Si on fait la somme de tous ces éléments, on a alors un total de 55714 octets. Sachant que ce sont les données spécifiques à la carte, il faut donc y ajouter les textures, sons, etc.

Faisons des stats. J’ai bricolé un petit script Python qui permet d’extraire les lumps d’un WAD. Si je prends doom1.wad (le shareware) j’obtiens:

  • 1264 lumps
  • Taille totale 4196037 octets (~4.1Mo)
  • Tailles des data des niveaux : 848149 octets (~828ko)
  • Sprites (délimité par S_START) : 825576 octets (~806ko)
  • Patch (délimité par P_START) : 763612 octets (~745ko)
  • Flat (délimité par F_START) : 221184 octets (~216ko)
  • Démos (il y en a 3 de base) : 44026 octets
  • Les musiques sont identifiées par D_xxx : 245179 octets (~239ko)
  • Les sons sont identifiés par Dxxx et sont entre les niveaux et les musiques : 538182 octets (~525ko)
  • TEXTURE1 : 9234
  • PNAMES : 2804
  • HELP1, HELP2, CREDITS et TITLEPIC : 68168 chacuns, donc 272672 au total
  • PLAYPAL, COLORMAP, ENDOOM : 23456
  • Par élimination le reste fait donc 384713 octets (~375ko)

Si on fait un camembert crado ça donne:

""

Faisons le même exercice avec DOOM.WAD:

  • 2306 lumps
  • Taille totale 12408309 octets (~12Mo)
  • Tailles des data des niveaux : 3622871 octets (~3.5Mo)
  • Sprites : 2220668 octets (~2.2Mo)
  • Patch : 2713040 octets (~2.7ko)
  • Flat : 438272 octets (~430ko)
  • Démos (il y en a 4) : 35008 octets
  • Musiques : 608128 octets (~600ko)
  • Sons : 691603 octets (~690ko)
  • TEXTURE1 : 9234
  • TEXTURE2 : 8036
  • PNAMES : 2812
  • HELP1, HELP2, CREDITS et TITLEPIC : 515396
  • PLAYPAL, COLORMAP, ENDOOM : 23456
  • Par élimination le reste fait donc ~1.5Mo

Ça fait beaucoup, beaucoup de données. Maintenant tâchons d’analyser correctement. Les données communes, c’est à peu près tout sauf les niveaux et les musiques, ce qui laisse quand-même beaucoup de monde. Sur ce WAD j’arrive à 6805987 octets. Pour 8Mo ça passe, mais ya pas beaucoup de marge. Sachant qu’il faut aussi charger les données du niveau et la musique. Si je prend le pire cas, ça ajoute 59ko (plus gros lump de musique) et 190ko (plus gros niveau). Donc ça passe !

En plus ce WAD est difficile à analyser, parce qu’il y a de la redondance, en fait il contient doom1.wad, mais il y a des lumps qui sont en double. Très bizarre.J’ai fait un tableau avec les stats de plusieurs WADs, pour pouvoir comparer:

""

J’ai pris tous les WADs commerciaux, sauf ceux qui incluent du script ou des trucs vraiment trop différents (Strife, Shex Quest, Hexen …), et j’ai même mis quelques PWAD (Requiem, Scythe 1 et 2, Alien Vandetta, Hell Revealed, Kama Sutra et PRCP). Sachant que, donc, pour les PWAD il faut garder en tête qu’ils requièrent de charger un IWAD, et tous ceux-là nécessitent au moins Doom2 (ou TNT ou Plutonia qui contiennent les assets de Doom 2). Dans tous les cas on constate tout de suite le problème : il n’y a que Doom shareware et retail et Heretic qui rentrent dans 8Mo, tous les autres demandent plus de place …

Donc maintenant il faut voir ce que je fais.

  • Soit je continue pour faire tourner Doom et Heretic uniquement, ce qui est déjà cool,
  • Soit je réfléchis à un système de chargement partiel des éléments, mais il faut déjà que je vérifie que cela permet effectivement de charger moins de données à un moment donné (et ça met une contrainte sur les données qui peuvent être présentes dans un même niveau),
  • Soit je réfléchis à un système de chargement dynamique, qui va chopper des données à la volée dans la flash, quitte à perdre de la perfo,
  • Soit je cherche une autre carte avec plus de RAM, mais il faudra réadapter le bas-niveau,
  • Soit j’upgrade cette carte avec une RAM plus grosse (à priori le double suffit, le plus gros PWAD fait 12Mo), ce qui suppose de déssouder l’actuelle et la remplacer … ce qui rend le projet moins portable d’un coup,
  • Soit j’abandonne.

… Grmpf …

Pour ce qui est d’upgrader, pour rappel la RAM d’origine sur la carte est une ISSI IS42S16400J. Dans la même famille, même boîtier, on monte jusqu’à 512Mb, donc c’est possible. Entre 2€ et 4€ le composant, ce n’est pas ruineux en plus. Il faudra que j’en prenne une dans ma prochaine commande Mouser, avec une carte en rab pour tester l’upgrade.

Creusons un peu dans le détail et regardons comment savoir le contenu exact d’un niveau, au niveau sprites et textures. Dans doomdata.h, on a un enum avec un mini-descriptif des différents lumps qui composent un niveau:

  • ML_LABEL, // A separator, name, ExMx or MAPxx
  • ML_THINGS, // Monsters, items..
  • ML_LINEDEFS, // LineDefs, from editing
  • ML_SIDEDEFS, // SideDefs, from editing
  • ML_VERTEXES, // Vertices, edited and BSP splits generated
  • ML_SEGS, // LineSegs, from LineDefs split by BSP
  • ML_SSECTORS, // SubSectors, list of LineSegs
  • ML_NODES, // BSP nodes
  • ML_SECTORS, // Sectors, from editing
  • ML_REJECT, // LUT, sector-sector visibility
  • ML_BLOCKMAP // LUT, motion clipping, walls/grid element

Un peu plus bas il y a une structure qui contient des infos de texture;

// A SideDef, defining the visual appearance of a wall, // by setting textures and offsets. typedef struct { short textureoffset; short rowoffset; char toptexture[8]; char bottomtexture[8]; char midtexture[8]; // Front sector, towards viewer. short sector; ` } PACKEDATTR mapsidedef_t;

Si on fait une recherche dans les sources de l’occurrence de cette structure, on la voit apparaître dans p_setup.c, dans la fonction P_LoadSideDefs, ce qui laisse penser que c’est le lump SIDEDEFS qui contient les informations de textures. D’ailleurs, il semblerait que ce fichier soit celui qui contient les fonctions appelées au chargement des niveaux, via la fonction P_SetupLevel:

` // DESCRIPTION: // Do all the WAD I/O, get map description, // set up initial state and misc. LUTs.

Cette fonction est appelée dans g~game.c,\ dans\ la\ fonction\ G~DoLoadLevel. C’est une de ces fonctions (SetupLevel ou DoLoadLevel) qu’il faudra modifier. Pour le moment on va juste chercher les données de lump dans la RAM, il faut y ajouter la suppression des données du niveau précédent, puis la copie des données du niveau actuel dans la RAM, avant la recherche des lumps.

Mais revenons sur nos définitions de textures.

La structure est la suivante:

  • Chaque LINEDEF du niveau se voit attaché un SIDEDEF,
  • Un SIDEDEF définit les différentes textures associées au LINEDEF (Upper, Lower et Middle), les textures sont définies par leur nom,
  • Les noms des textures sont listés dans les lumps TEXTURE1 et TEXTURE2,
  • Chaque entrée dans TEXTUREx renvoie vers des patches, un ou plusieurs,
  • Les patches sont listés dans le lump PNAMES,
  • Les patches listés dans PNAMES sont définis par leur noms, et sont listés dans les lumps de patches.

Donc, pour savoir quelle est la quantité de données de texture requise par un niveau, il faut lister ses SIDEDEFs, chopper toutes les textures différentes, suivre TEXTURE pour avoir les patches correspondants, puis lister tous les patches uniques requis, et check leurs tailles. Les textures en elle-mêmes sont stockées dans les lumps PATCH sous le format Doom picture format. Il y a une indication de largeur (width) et de hauteur (height) dans le header, c’est ce que l’on va utiliser pour calculer la place prise par le patch. Et donc on multiplie le nombre de patches par la taille de la texture. Sachant que tout cela ce n’est que des pointeurs, et un même patch peut être utilisé sur plusieurs textures, mais qu’il n’est chargé qu’une seule fois, ce qui rend le comptage pas évident, car il faut compter les patches qui sont utilisés au moins une fois.

Tout cela a une autre conséquence, c’est qu’il faut faire une translation entre les adresses “naturelles” dans le WAD et les adresses “réelles” une fois qu’on a chargé uniquement ce qui est nécessaire. En effet, les adresses dans le WAD correspondent à la position dans le WAD, mais si on ne charge qu’une partie des données, elles ne seront plus positionnées de la même façon relativement les unes aux autres. Et il ne faudrait pas que cette opération de translation nécessite tellement de ressources que ça annule le gain de place de ne pas tout charger systématiquement. J’ai de plus en plus l’impression que je suis en train de ramer pour gagner des queues de cerises.

Navigateur de WADs

Je n’ai pas trouvé d’exemple “tout fait” d’explorateur de file system pour les Nucleo / Discovery, ce qui est … étonnant. Par contre, il y en a un - ou du moins quelque chose qui y ressemble - dans le programme d’exemple qui est dans la Discovery. Je vais tâcher de chopper les sources et regarder comment c’est fait dedans.

Son

Dans le code, dans i~sound.c floppes a mis sous le switch ORIGCODE les include des libs SDL et d’émulation de GUS (Gravis UltraSound),\ les variables liées aux IRQ /\ DMA (celles du DOS),\ ainsi que tout le contenu de la fonction I~BindSoundVariables, ce qui veut dire que les fonctions pour jouer du son ne sont pas bindées, donc jamais appelées.

J’ai vu passer des exemples de portage qui ont du son. J’aimerais bien pouvoir m’en inspirer, mais il n’y en a pas dont les sources sont partagées … Donc il va falloir se démerder.

A mon avis, dans les grandes lignes, il va s’agir de faire un séquenceur qui va envoyer des samples vers une sortie, potentiellement un DAC, plus probablement une sortie PWM / PDM. En tous cas il va falloir streamer des samples à la bonne fréquence d’échantillonnage. Pour ça, il faut un timer à la bonne fréquence, un buffer en RAM, une interruption appelée par le timer, qui va charger le prochain sample dans le registre de sortie (DAC ou PWM). Pour être le plus transparent possible au niveau de l’exécution, il faut passer par du DMA, et ne pas passer dans les routines d’interruption. Dans ce buffer on mettra le résultat du mixage.

Pour le stockage des samples, soit on a tous les samples dans la RAM, soit on va les chercher dans la flash à la volée. Tout dépend de la vitesse d’accès.

Comment et où ajoute-t-on cette interface - qui est un driver en fin de compte ? Il faut regarder dans le code de Chocolate Doom, car il manque des bouts dans le code de floppes, ce qui est logique : il n’a pas mis ce qui ne sert pas.

Les interfaces bas-niveau “target specific” sont gérées dans les modules qui commencent par “i_”. Ici on a donc i_sound.c et i_sound.h. On y trouve des instanciations de structures sound_module_t:

typedef struct { // List of sound devices that this sound module is used for. snddevicet *sounddevices; int num_sound_devices; // Initialise sound module // Returns true if successfully initialised boolean (*Init)(boolean use_sfx_prefix); // Shutdown sound module void (Shutdown)(void); // Returns the lump index of the given sound. int (GetSfxLumpNum)(sfxinfo_t sfxinfo); // Called periodically to update the subsystem. void (Update)(void); // Update the sound settings on the given channel. void (UpdateSoundParams)(int channel, int vol, int sep); // Start a sound on a given channel. Returns the channel id // or -1 on failure. int (*StartSound)(sfxinfo_t *sfxinfo, int channel, int vol, int sep, int pitch); // Stop the sound playing on the given channel. void (StopSound)(int channel); // Query if a sound is playing on the given channel boolean (*SoundIsPlaying)(int channel); // Called on startup to precache sound effects (if necessary) void (*CacheSounds)(sfxinfo_t *sounds, int num_sounds); } sound_module_t;

Dans cette structure on trouve une série de pointeurs de fonction, chacun correspondant à une “action” : initialisation, jouer un son, jouer une musique … En fonction de la cible, on va les faire pointer vers les fonctions qui permettent de driver le hardware. Dans Chocolate Doom on a les fichiers i_sdlsound.c , i_pcsound.c, i_oplmusic.c, etc … Chacun correspond à une “cible”, et contient une instance de la structure sound_module_t avec les fonctions associées. En fonction du périphérique sélectrionné dans la configuration de Chocolate Doom, il va aller utiliser les pointeurs de la structure correspondante.

Donc, pour adapter tout cela au HW de la carte, il faut écrire un fichier i_stm32sound.c et son copain .h, mettre dedans toutes les fonctions bas-niveau de driver, déclarer une structure qui va contenir les pointeurs vers ces fonctions, et l’instancier le tout dans i_sound.

Exemple avec i_sdlsound.c. Si on regarde à la fin, on a la déclaration de la structure, avec les assignation des pointeurs de fonctions:

sound_module_t sound_sdl_module = { sound_sdl_devices, -> sound_devices arrlen(sound_sdl_devices), -> num_sound_devices I_SDL_InitSound, ->Init I_SDL_ShutdownSound, -> Shutdown I_SDL_GetSfxLumpNum, -> GetSfxLumpNum I_SDL_UpdateSound, -> Update I_SDL_UpdateSoundParams, -> UpdateSoundParams I_SDL_StartSound, -> StartSound I_SDL_StopSound, -> StopSound I_SDL_SoundIsPlaying, -> SoundIsPlaying I_SDL_PrecacheSounds, -> CacheSounds };

Regardons par exemple la fonction qu’ils ont assigné sur StartSound:

static int I_SDL_StartSound(sfxinfo_t *sfxinfo, int channel, int vol, int sep, int pitch) { allocated_sound_t *snd; if (!sound_initialized || channel < 0 || channel >= NUM_CHANNELS)
{ return -1; } // Release a sound effect if there is already one playing
// on this channel ReleaseSoundOnChannel(channel); // Get the sound data
if (!LockSound(sfxinfo)) { return -1; } snd = GetAllocatedSoundBySfxInfoAndPitch(sfxinfo, pitch); if (snd == NULL)
{ allocated_sound_t *newsnd; // fetch the base sound effect, un-pitch-shifted snd = GetAllocatedSoundBySfxInfoAndPitch(sfxinfo, NORM_PITCH); if (snd == NULL) { return -1; } if (snd_pitchshift) { newsnd = PitchShift(snd, pitch); if (newsnd) { LockAllocatedSound(newsnd); UnlockAllocatedSound(snd); snd = newsnd; } } } else { LockAllocatedSound(snd); } // play sound Mix_PlayChannel(channel, &snd->chunk, 0); ===> C’est cette fonction qui va effectivement lancer le son channels_playing[channel] = snd; // set separation, etc. I_SDL_UpdateSoundParams(channel, vol, sep); return channel; ` }

Dans notre driver, on va donc mettre ici:

  • Le dépilage du contenu de la structure sfxinfo_t qui est donnée en paramètre
  • Le calcul du pitch et du volume
  • Le chargement du son pour qu’il soit joué, donc dans le mixeur. Mixeur qui ira ensuite charger le buffer de sortie avec les échantillons kivonbien.

Musique

Un membre du Lab me fait remarquer que certaines cartes sons utilisaient OPL pour faire la musique. Et il se trouve qu’il y a une lib OPL dans les sources de Chocolate Doom. Bonne piste à suivre. Voyons plus en détail ce que propose Chocolate Doom.

Tout n’est pas aussi rose. De ce qui y est écrit, les options possibles pour la musique sont:

  • Utiliser le support natif du MIDI par l’OS : pas d’OS ici -> Non.
  • Timidity, inclus dans SDL2 : sous-entend un portage de Timidity … A creuser.
  • Gravis UltraSound, using Timidity under the hood : donc idem précédent, en plus lourd.
  • OPL : n’est pas une émulation, mais permet le support de cartes son qui auraient une puce OPL hardware, ce qui n’est pas du tout le cas ici.

Grmpf … Soit je trouve une version portée sur STM32 de Timidity ou un émulateur d’OPL, soit je vais devoir le développer … Ça sent pas bon :(

J’ai cherché quelques sources de synthé MIDI sur STM32. Pas de port complet tirant vers le general MIDI, a priori. Le plus souvent les projets sur STM32 sont monophoniques … Quelques liens intéressants quand-même:

Je suis en train de me demander si ça peut vraiment aboutir, intégrer la génération de la musique avec le reste. Je me demande si à minima ce ne serait pas mieux que ça soit un projet séparé, sur une carte dédiée. En tous cas, ça sera le dernier truc sur lequel je vais bosser dans ce projet global, parce que c’est le plus gros morceau, et il n’est pas question que ça soit bloquant sur le reste.

Soyons un peu plus précis concernant le traitement de la musique dans Doom.

Le format exact n’est pas du MIDI, mais du MUS. Néanmoins, la transcription automatique MIDI -> MUS est supportée à partir de la version 1.5 de Doom. Il est donc hautement probable que Chocolate Doom le gère.

Et de fait, dans i_sdlsound.c il y a une fonction ConvertMus qui est appelée au chargement des lumps de musique, et si identifié comme étant au format MUS, le convertit en MIDI:

static boolean ConvertMus(byte *musdata, int len, const char *filename) { MEMFILE *instream; MEMFILE *outstream; void *outbuf; size_t outbuf_len; int result; instream = mem_fopen_read(musdata, len); outstream = mem_fopen_write(); result = mus2mid(instream, outstream); ===> Conversion MUS -> MIDI if (result == 0) { mem_get_buf(outstream, &outbuf, &outbuf_len); M_WriteFile(filename, outbuf, outbuf_len); } mem_fclose(instream); mem_fclose(outstream); return result; }

D’ailleurs, il va carrément créer un fichier MIDI (M_WriteFile) … Quelle brutalité …

Donc c’est pas gagné. Il va falloir réfléchir à la façon d’extraire les données, et comment les retranscrire. Va y avoir du taf ! Je suis même en train de me dire qu’il vaudrait mieux que je gère le son sur une carte séparée qui ne ferait que synthétiseur MIDI.

- Flax