Programmation avec le 6502 : découverte de la NES

Magazine
Marque
Hackable
Numéro
34
Mois de parution
juillet 2020
Spécialité(s)


Résumé

Dans les articles précédents, nous avons étudié de près le langage d'assemblage du microprocesseur 6502. Et même si j'ai essayé d'étayer le tout avec beaucoup d'exemples, tout cela est resté très théorique. Aujourd'hui, nous allons vraiment passer à la pratique en réalisant des programmes graphiques pouvant s'exécuter sur une véritable console NES ou sur un émulateur.


Body

La NES (Nintendo Entertainment System) a été l'une des premières consoles grand public. Et c'est en tout cas la toute première proposée par Nintendo. Sa résolution graphique était de 256x240 pixels, avec assez peu de couleurs : un maximum de 25 à choisir parmi une palette fixe de 56. Ses capacités sonores étaient également très limitées.

Les jeux se présentaient sous la forme de cartouches assez massives que l'on insérait directement dans la console. Ces cartouches contenaient le code et les données du jeu dans des ROM directement accessibles par le processeur, ce qui fait que les temps de chargement étaient quasi nuls. Les joueurs pouvaient interagir généralement par le biais de deux joypads, mais également avec le célèbre pistolet optique orange de Duck Hunt, et plein d'autres interfaces ont vu le jour avec le temps.

1. L'architecture générale de la NES

L'architecture de la NES est très simple pour une console permettant de faire tourner autant de jeux. Il n'y a en effet que très peu de composants actifs sur sa carte électronique. Les deux principaux sont le PPU, le processeur graphique qui va nous intéresser aujourd'hui et le Ricoh 2A03, qui est une puce rassemblant un 6502 et un générateur de sons assez rustique nommé APU (voir [2]). On trouve également deux boîtiers de 2 Ko de RAM : un pour le 6502 et un pour le PPU, ainsi qu'une puce d'identification des cartouches (non représentée), une sorte de DRM de l'époque, évidemment obsolète. La figure 1 montre tout cela de manière schématique.

v-archi

Figure 1 : Architecture électronique de la NES (nombre de pattes non contractuel).

1.1 L'espace d'adressage de la NES

Le 6502 peut adresser 64 Ko de mémoire, mais la NES ne dispose que de 2 Ko de RAM (sur la console elle-même) et de 16 Ko ou 32 Ko de ROM (sur les cartouches de jeu). Cela laisse donc de la place pour autre chose, et c'est là que les concepteurs de cette console ont été très malins. Comme on peut le voir sur la figure 2, la RAM est placée au début de la mémoire. On pourra donc lire et écrire dans les adresses de $0000 à $07FF, soit les deux premiers Ko de la mémoire. Cela est une bonne idée, car les 256 premiers octets sont un peu particuliers (voir les épisodes précédents) puisqu'ils sont utilisés dans certains modes d'adressage dit « rapides ». De plus, la pile est toujours entre $0100 et $01FF. Il reste donc 6 * 256 = 1536 octets de mémoire RAM « normale ».

Pour des raisons de simplicité électronique, de $0800 à $1FFF, on retrouve trois fois exactement la même chose qu'entre $0000 et $07FF. C'est à dire que si on lit ou écrit par exemple à l'adresse $0A15, cela aura exactement le même effet que de lire ou écrire à l'adresse $0215 (ou l'adresse $1215 ou encore $1A15).

Écrire ou lire dans les adresses de $2000 à $2007 permet en fait de discuter avec le PPU, le processeur graphique. Ces huit adresses permettent de lire et modifier ses 8 registres publics. Par exemple, écrire un 0 à l'adresse $2000 va complètement désactiver l'affichage, mais cela est détaillé dans la suite de cet article. On dit que ces adresses sont en fait des I/O (input/output).

Comme pour la RAM, la zone des registres du PPU est dupliquée (1024 fois !!!) de $2008 à $200F, de $2010 à $2017, etc. jusqu'à de $3FF8 à $3FFF. Là aussi, cela a été fait pour simplifier l'électronique de la NES.

Viennent ensuite 18 registres de l'APU, de $4000 à $4017, qui sont encore des I/O, permettant cette fois de gérer le son et... les joypads (oui, cela peut sembler curieux, mais ce genre d'association un peu contre nature est en fait assez courante : sur Atari ST, c'était le processeur sonore qui gérait quelle était la face active du lecteur de disquette).

Il y a une partie vide entre $4018 et $7FFF : écrire dans cette zone n'aura aucun effet et lire depuis cette zone pourra renvoyer n'importe quoi. Certaines cartouches de jeux utiliseront cette zone pour ajouter des possibilités à la NES.

Et on retrouve finalement la ROM, c'est à dire le contenu du jeu (code et données, hormis les données purement graphiques) soit de $8000 à $FFFF (pour les cartouches de 32 Ko) soit de $C000 à $FFFF (pour les cartouches de 16 Ko). Donc si on n'a que 16 Ko, ils sont forcément à la fin.

v-memoire

Figure 2 : résumé du mapping mémoire de la NES.

1.2 Les cartouches

Les cartouches de jeux pour NES sont elles aussi très simples, même si elles sont devenues de plus en plus complexes au fil du temps. Dans leur configuration de base, elles ne sont composées que de deux puces de type ROM : une de 16 Ko ou 32 Ko contenant les données et le code qui sera exécuté par le 6502 et utilisé comme nous venons de le voir, et une de 8 Ko contenant les données graphiques destinées au PPU qui se chargera d'y accéder directement lui-même. Le PPU « verra » cette ROM aux adresses de $0000 à $1FFF dans sa mémoire à lui.

Beaucoup de jeux, dont l'immense majorité de ceux publiés lors des premières années d'exploitation de la NES, utilisèrent exactement ce genre de cartouches. Nous reviendrons sur les cartouches plus complexes dans un prochain article.

2. Présentation du PPU

Le Pixel Processor Unit (PPU) est un véritable processeur, avec ses registres, son horloge, ses instructions, etc., et qui accède à de la mémoire ROM et RAM pour produire les pixels que l'on verra à l'écran. Cependant, la ROM (de 8 Ko) et la RAM (de 2 Ko) ne peuvent contenir que des données graphiques, mais pas de code. Ainsi, le PPU n'est pas vraiment programmable par nous, on ne pourra donc utiliser ses registres que pour le paramétrer.

2.1 La composition des images de fond sur NES

Mais avant cela, il nous faut comprendre comment sont représentées les images sur la NES. L'écran de la NES fait 256 pixels de large sur 240 de haut, soit 61440 pixels. Or, la VRAM (la RAM du PPU) n'est que de 2 Ko, soit 16384 bits. On a donc 4 fois plus de pixels que de bits dans la RAM ! Il est donc impossible d'accéder à chaque pixel comme dans un framebuffer classique, même en considérant les 8 Ko de ROM (il y aurait tout juste la place pour une seule image statique monochrome).

Au lieu de cela, on utilise un système à base de tuiles. Chaque tuile est un bloc de 8x8 pixels, où chaque pixel occupe deux bits et peut donc prendre 4 valeurs (00, 01, 10, 11). Chaque tuile occupe donc 8x8x2 = 128 bits soit 16 octets. Et ce sont ces tuiles (et uniquement elles) que l'on va retrouver dans la mémoire ROM du PPU, sur la cartouche. Cette mémoire est de taille imposée et fait 8 Ko, on va donc avoir 8192 / 16 = 512 tuiles disponibles, réparties en 2 banques de 256 tuiles. À chaque instant, une seule banque de 256 tuiles est disponible pour les tuiles du fond de l'écran, les 256 autres tuiles sont utilisées pour les sprites. Dans cet article, on s'intéressera uniquement au dessin du fond d'écran, l'utilisation des sprites fera l'objet du prochain article.

Le fond de l'écran, qui fait 256x240 pixels, est donc en fait composé de 32x30 (=960) tuiles. À chaque emplacement de 8x8 pixels à l'écran, on a donc juste une seule valeur (entre 0 et 255, un octet) qui représente un numéro de tuile dans la banque courante. Ces 960 octets sont appelés Nametable (voir figure 3). Comme il y a presque 4 fois plus d'emplacements à l'écran que de tuiles disponibles, chaque tuile est généralement utilisée plusieurs fois.

v-nametable

Figure 3 : composition d'une image avec le système de tuiles.

Cependant, avec seulement 2 bits par pixels, on ne peut a priori représenter que 4 couleurs. Aussi, pour ajouter un peu de variété, Nintendo a utilisé la notion d'attributs. Chaque portion de 16x16 pixels (soit 2x2 tuiles) à l'écran se voit affecter un attribut de 2 bits. Ces deux bits peuvent représenter 4 valeurs qui sont des numéros de palette de 4 couleurs chacune. Ces bits d'attributs sont regroupés quatre par quatre pour former un octet qui indiquera les palettes à utiliser pour chaque bloc de 32x32 pixels à l'écran. Et comme 8*32 = 256, il y a en tout 8x8 = 64 octets d'attributs pour un écran. Si l'on ajoute les 960 octets désignant les tuiles, on a exactement 1024 octets (= 1 Ko) pour un écran. La RAM du PPU étant de 2 Ko, on peut donc y stocker exactement deux écrans (dont un seul est visible à la fois, évidemment).

Les palettes sont à leur tour composées de numéros de couleurs entre 0 et 64, mais seulement 56 sont différentes (voir figure 4). À noter une contrainte supplémentaire : dans toutes les palettes, la première couleur doit être la même ! On a donc une couleur commune plus quatre fois trois couleurs, soit 13 couleurs pour tout le fond d'écran. Et pas plus de 4 couleurs différentes par groupe de 16 pixels. Il existe aussi une seconde palette de 16 couleurs, avec les mêmes restrictions, utilisée pour dessiner les sprites. Ces deux palettes de couleurs sont stockées directement dans la RAM interne du PPU et pas dans une puce à part.

NES-palette

Figure 4 : La palette des couleurs de la NES.

Tout cela peut paraître complexe, et ça l'est ! La figure 5 devrait cependant vous aider à y voir plus clair. On voit un bloc de 32x32 pixels, soit 4x4 tuiles. Sur la gauche, en noir et blanc, chaque tuile est représentée par un nombre entre 0 et 255, un index dans la banque des tuiles. Au milieu, un attribut avec ici 4 valeurs différentes pour chaque groupe de 16x16 pixels. Et sur la droite, on voit le résultat à l'écran, avec le coin supérieur gauche dessiné en utilisant la première sous-palette de 4 couleurs (la 00), le coin supérieur droit avec la seconde sous-palette (la 01), etc.

v-attributs

Figure 5 : composition d'une image à partir des tuiles, des attributs et de la palette.

2.2 La mémoire du PPU

Comme indiqué précédemment, le PPU est le cœur de la partie graphique de la NES. Il consiste en un système indépendant, et il a donc son propre mappage mémoire.

Il est important de noter que la mémoire vue par le PPU est totalement distincte de celle vue par le CPU. Elle est stockée dans des puces différentes, gérée différemment et une même valeur d'adresse pour l'une ou pour l'autre représentera des données totalement différentes.

v-ppu-memory

Figure 6 : l'espace d'adressage du PPU.

La figure 6 montre comment le PPU voit le monde qui l'entoure. La définition des tuiles est en ROM, donc figée une fois pour toutes pour un jeu donné. On trouve deux banques de 256 tuiles chacune vues par le PPU entre $0000 et $1FFF. Puis quatre « écrans » de 1024 octets chacun : 30*32=960 numéros de tuiles et 64 octets d'attributs sont présents dans la RAM du PPU aux adresses $2000, $2400, $2800 et $2C00. Enfin, les deux palettes courantes de 16 couleurs chacune sont stockées aux adresses $3F00 à $3F1F.

Les quatre écrans stockés dans la RAM sont agencés comme indiqué sur la figure 7. En temps normal, c'est le premier écran ($2000) qui est visible. Mais on verra à la fin de cet article qu'il est possible de demander au PPU de se décaler dans cet agencement et de montrer une partie de chaque écran, comme par exemple la partie pointillée sur la figure 7.

ecrans

Figure 7 : l'agencement des écrans.

Les lecteurs attentifs auront remarqué que la RAM externe du PPU est de 2 Ko alors qu'elle est censée contenir quatre écrans de 1 Ko chacun. Ça dépasse. En fait, une cartouche de jeu peut choisir entre deux réglages possibles avec à chaque fois seulement deux écrans différents :

  • soit on utilise $2000 et $2400, pour les jeux à scrolling horizontal, et dans ce cas $2800 est une copie de $2000 et $2C00 est une copie de $2400 ;
  • soit on utilise $2000 et $2800, pour les jeux à scrolling vertical, et dans ce cas $2400 est une copie de $2000 et $2C00 est une copie de $2800.

Cela peut sembler tordu, mais cela s'avère assez pratique à l'usage et cela a permis à certaines cartouches de jeux de proposer 2 Ko de RAM PPU supplémentaires au prix de quelques magouilles électroniques. Cela a été utilisé notamment par le jeu Gauntlet afin de proposer un scrolling multidirectionnel particulièrement impressionnant à l'époque.

2.3 Transfert de données

Les données qu'on peut envoyer au PPU sont soit des indices de tuiles pour un endroit donné (de 8x8 pixels) de l'écran (nametable), soit un attribut indiquant les 4 sous-palettes de couleurs utilisées pour un bloc de 32x32 pixels de l'écran, soit une couleur de la palette.

Mais on ne peut pas accéder à la RAM du PPU directement depuis le CPU, il va falloir passer par le biais des registres du PPU. La figure 8 montre comment les registres du PPU sont vus par le CPU (à quelle adresse) et à quoi ils servent.

v-ppu-registers

Figure 8 : les registres du PPU.

Mais les registres du PPU sont un peu particuliers. On ne pourra pas les additionner, les comparer, les incrémenter, etc. Au contraire, chacun de ces registres a un rôle très précis et doit être utilisé de la bonne façon. Par exemple, on écrira dans le registre PPUDATA (à l'adresse $2007) pour transmettre une donnée au PPU pour qu'il la range dans sa RAM.

Et pour savoir où il doit ranger la donnée qu'on lui transmet ainsi, le PPU consultera son registre PPUADDR. Ce registre est sur 16 bits alors que les écritures du 6502 ne peuvent être que de 8 bits à la fois. Aussi, On pourra modifier PPUADDR en écrivant deux fois de suite à l'adresse $2006, en commençant par le poids fort.

Par exemple, l'extrait de code suivant permet de placer la donnée 42 à l'adresse $2000 de la RAM du PPU (et donc de changer la tuile du tout premier bloc de 8x8 pixels de l'écran).

  LDA #$20 ; poids fort de #$2000
  STA $2006 ; PPUADDR
  LDA #$00 ; poids faible de #$2000
  STA $2006 ; PPUADDR
  LDA #42   ; notre donnée
  STA $2007 ; PPUDATA

C'est un peu long pour écrire un seul octet ! Heureusement, le PPU regorge d'astuces et il incrémente tout seul PPUADDR à chaque écriture dans PPUDATA, ce qui permet d'accélérer grandement les écritures successives. Par exemple, pour écrire 42 à l'adresse $2000 et 82 à l'adresse $2001, on pourra utiliser le bout de code suivant :

  LDA #$20 ; poids fort de #$2000
  STA $2006 ; PPUADDR
  LDA #$00 ; poids faible de #$2000
  STA $2006 ; PPUADDR
  LDA #42   ; notre première donnée
  STA $2007 ; PPUDATA
  LDA #82   ; notre seconde donnée
  STA $2007 ; PPUDATA

Ceci dit, même ainsi, écrire dans la RAM du PPU s'avère particulièrement lent, ce qui explique que les jeux sur NES ne modifient pas beaucoup les décors d'une frame à l'autre.

2.4 Contrôler le fonctionnement du PPU

Le fonctionnement général du PPU est contrôlé via deux autres de ses registres. Premièrement, PPUCTRL auquel on accède en écrivant à l'adresse $2000. Chaque bit de ce registre a un sens particulier, permettant d'activer ou non l'interruption verticale, de choisir la taille des sprites, de définir dans lequel des quatre écrans l'affichage commence ou encore de choisir si les tuiles pour le fond doivent être prises dans la première ou deuxième banque. Tout cela est bien complexe et on détaillera chaque fonctionnalité au moment où l'on en aura besoin.

Pour l'instant, on gardera simplement à l'esprit qu'en écrivant 0 dans ce registre, on suspend une bonne partie de l'activité du PPU, alors que la valeur 144 (%10010000 en binaire) le remettra en ordre de marche.

Deuxièmement, le PPU est contrôlé par le registre PPUMASK accessible à l'adresse $2001. Là encore, chaque bit a un sens permettant d'indiquer si on veut activer l'affichage du fond et/ou des sprites, si la toute première colonne doit être affichée ou non et si on veut rendre les couleurs plus vives ou au contraire passer toute l'image en niveau de gris.

Ici aussi, écrire 0 dans ce registre désactivera le fonctionnement du PPU (il n'affichera plus rien à l'écran) et une valeur qui réactive le tout peut être 62 (%00011110 en binaire).

Ainsi, avant d'écrire massivement (et cacher momentanément les changements), on désactivera le PPU avec la séquence suivante :

  LDA #0
  STA $2000 ; PPUCTRL
  STA $2001 ; PPUMASK

Et on réactivera le tout avec l'extrait suivant :

  LDA #%10010000 ; réactivation NMI
  STA $2000 ; PPUCTRL
  LDA #%00011110 ; affichage fond d'écran & sprites
  STA $2001 ; PPUMASK

Notez que l'on n'aura pas besoin de faire ça très souvent normalement. Ce sera surtout utile au moment de l'initialisation ou lorsque l'on voudra redessiner tout un écran, par exemple.

Le dernier registre du PPU qui va nous être indispensable pour notre premier programme est PPUSTATUS ($2002). Ce registre n'est accessible qu'en lecture, et seul son bit 7 nous intéresse pour l'instant. L'opcode BIT qui permet justement de tester ce bit semble vraiment être fait pour surveiller ce registre.

Le PPU met à 1 ce bit lorsqu'il a fini de dessiner un écran et le remet à 0 lorsqu'il recommence à dessiner la première ligne de l'écran suivant. Cela nous sera utile pour synchroniser nos opérations avec le dessin. En effet, il n'est possible d'écrire dans un des registres du PPU que lorsqu'il n'est pas occupé à dessiner l'écran. Le meilleur moment pour cela est donc le moment où il a fini de dessiner la dernière ligne de l'écran jusqu'à l'instant où il devra commencer à dessiner la première ligne de l'écran lors de la frame suivante. On appelle ce moment la VBL pour Vertical BLank (à ne pas confondre avec la notion d'intervalle VBLANK d'un signal vidéo, qui est liée, mais purement électronique).

3. Notre premier programme pour NES

Avec tout ceci, vous devez être impatient de mettre tout ça en œuvre pour créer votre premier programme qui affiche quelque chose sur une NES (ou un émulateur). Mais avant ça, il nous reste quelques petites choses à savoir.

3.1 Les interruptions

Lors du premier article de cette série, nous avons passé en revue tous les opcodes du 6502. Parmi ceux-ci, il y avait RTI qui permet de terminer une routine de traitement d'interruption, ce qui laisse supposer que ce processeur sait gérer les interruptions. En fait, cela est assez rudimentaire avec le 6502, mais suffisant pour nos besoins. Il y a 3 seulement trois types d'interruptions disponibles :

  • RESET, qui arrive quand on (re)démarre la NES ou que l'on presse le bouton du même nom. La routine associée initialise le programme et ne se termine jamais vraiment.
  • NMI (Non Maskable Interrupt, interruption non masquable). Sur la NES, cette interruption est générée par le PPU lorsqu'il est en mode normal (non désactivé) à chaque fois qu'il a fini de dessiner un écran. La routine associée à cette interruption sera donc le bon endroit pour écrire dans la RAM du PPU pour modifier l'affichage du prochain écran, par exemple. On appelle souvent cette routine VBL (Vertical BLank).
  • IRQ (Interrupt ReQuest, requête d'interruption). Cette interruption n'est pas utilisée par défaut sur la NES elle-même. Certaines cartouches de jeux assez évoluées l'utilisent pour certains effets, en l'associant à une routine qui sera appelée à chaque fin de ligne (tout comme la VBL est appelée à chaque fin d'écran).

J'indique que l'on peut associer des routines (des morceaux de code assembleur) à ces interruptions. Mais comment faire cela ? Sur le 6502, c'est assez simple, il suffit de placer leurs adresses à la fin de la mémoire (et donc en ROM pour nous). À l'adresse $FFFA (et $FFFB, une adresse est sur 16 bits), on placera l'adresse de l'interruption associée à la NMI. En $FFFC, on mettra l'adresse de la routine d'initialisation (RESET). En $FFFE, on mettra l'adresse de la routine IRQ, donc rien, puisqu'on ne l'utilise pas !

Ces 6 octets (3 adresses 16 bits) à la fin de l'espace mémoire du 6502 sont appelés vecteurs d'interruption.

3.2 L'assembleur ASM6

Si dans les épisodes précédents un assembleur/émulateur en ligne était suffisant pour tester les petits exemples proposés, nous allons avoir besoin d'un assembleur un peu plus costaud aujourd'hui.

Il y a plein d'assembleurs pour le 6502, chacun avec ses avantages et inconvénients. Vous en trouverez une bonne liste sur le wiki de NesDev ici [3].

J'ai choisi d'utiliser ASM6 dans mes exemples pour sa simplicité d'utilisation et l'ensemble des possibilités qu'il offre pour organiser le code. Vous le trouverez sur le lien [4]. L'archive contient un .exe pour Windows, et le fichier source C permettant de le compiler pour les autres plateformes. Il se compile avec un simple :

$ gcc asm6.c -o asm6

Un assembleur permet de convertir notre code (un simple fichier texte qu'on écrira avec ce qu'on veut, de Notepad à Vi pour les plus aventureux) en un fichier exécutable sur un émulateur, par exemple. On pourrait par la suite transformer ce fichier pour le placer dans de vraies ROM, sur une vraie cartouche de jeu pour l'utiliser dans une vraie NES, branchée à une vraie télé cathodique, avec de vrais joypads manipulés par un vrai joueur, mais je m'égare.

Le fichier source contenant notre code sera donc la suite des opcodes qui forment notre programme, mais pas uniquement. Les assembleurs acceptent généralement d'autres mots clefs qui ne seront pas retranscrits directement en code machine, mais qui permettent d'écrire beaucoup plus simplement. On appelle ces mots clefs « directives ». ASM6 vient avec sa documentation détaillée de chaque directive qu'il propose, ainsi que de ses différents paramètres.

Voici quelques exemples de ces directives :

  • EQU permet de définir un symbole, pour donner un nom à un nombre. Par exemple, PPUCTRL EQU $2000 nous permettra d'utiliser PPUCTRL à la place de $2000 pour parler du registre de contrôle du PPU, ce qui est plus pratique et plus lisible.
  • DC permet d'inclure des données constantes directement, en donnant leur valeur :
      donnees:   ; un label pour faire référence aux données
        DC.B 124   ; l'adresse "donnees" contiendra 124
        DC.W $1234 ; l'adresse "donnees+1" contiendra $34
                    ; et l'adresse "donnees+2" contiendra $12
        DC.B 'A'   ; l'adresse "donnees+3" contiendra 65 (le code ASCII de 'A')

C'est donc une sorte d'opcode générique, vous allez voir que l'on s'en sert beaucoup. DC.B (qui définit une donnée sur un octet) et DC.W (qui définit une donnée sur deux octets) sont généralement utilisés dans la partie code (la ROM), pas très loin du code qui se sert de ces données.

  • DS fonctionne un peu de la même manière, mais en donnant juste la taille des données que l'on veut, ce qui est pratique pour réserver de la place pour une variable en RAM (Je donne un exemple tout de suite après).
  • ENUM permet justement de définir une zone en RAM ou l'on placera nos données.
        ENUM $0300 ; les données suivantes seront accessibles à partir de l'adresse $0300
      compteur: DS.B 1
      position: DS.B 1
        ENDE ; fin de la zone
  • BASE va nous permettre de faire un peu la même chose, mais pour le code. Vous vous souvenez que la ROM du CPU, qui contient le code doit commencer en $8000 ou en $C000 ? Hé bien, on commencera la partie code de notre programme avec un BASE $C000, ce qui permettra à l'assembleur de générer les bonnes adresses de sauts par exemple.
  •  Enfin, ORG nous permettra de placer nos vecteurs d'interruption à la fin de la ROM sans avoir à remplir à la main la zone entre la fin de notre code et $FFFA, avec un simple ORG $FFFA. Notons qu'à la différence de BASE, ORG remplit la mémoire avant lui (pour qu'il n'y ait pas de trou entre la fin de notre code et $FFFA dans notre cas).

L'assembleur ASM6 offre plein d'autres possibilités, comme la gestion des répétitions d'un bout de code ou encore les macros, mais cela dépasse le cadre de cet article. On utilisera ces possibilités avancées dans la suite de cette série.

3.3 La structure générale

Il ne nous reste plus qu'à écrire le code de notre programme.

On va réaliser un exemple simple qui affiche juste « Hello, world », avant de partir dans une boucle infinie nous laissant admirer le résultat (figure 9).

hello-world-screenshot

Figure 9 : hello world sur NES !

Vous trouverez bien évidemment l'intégralité du code sur le GitHub du magazine [5], mais je vais en décrire les parties importantes.

Tout d'abord, comme notre code est aussi destiné aux émulateurs, il faut décrire le contenu et le type de cartouche de jeu qu'ils doivent émuler. Pour cela, avant le début de notre code, on devra placer un bloc de 16 octets servant d'en-tête. Ce bloc doit avoir la forme suivante :

   DC.B "NES", $1a ; l'en-tête doit toujours commencer ainsi
   DC.B 1          ; Le nombre de boîtiers de 16 Ko de ROM CPU (1 ou 2)
   DC.B 1          ; Le nombre de boîtiers de 8 Ko de ROM PPU
   DC.B 0          ; Le type de cartouche, ici on veut le plus simple
   DS.B 9, $00     ; puis juste 9 zéros pour faire 16 en tout

Il existe plus d'une centaine de types de cartouches, mais la grande majorité des jeux utilise les formats de cartouches les plus simples. L'octet contenant ce type contient aussi l'agencement des écrans du PPU (horizontal ou vertical). Pour notre programme exemple, l'orientation importe peu, et on laisse donc cela à 0.

Comme notre programme consiste en un affichage puis une attente, la plus grande partie du code consistera à correctement initialiser le tout. Et la routine correspondante est évidemment RESET. Elle commence ainsi :

  BASE $C000
RESET:
  LDA #0      ; Remise à zéro
  STA PPUCTRL ;   du Contrôle du PPU
  STA PPUMASK ;   du Mask du PPU
  ...

On commence en effet toujours par désactiver le PPU, afin que les initialisations n'affichent pas des choses bizarres tant que l'on n'a pas fini. Et au moment du RESET, le PPU se réveille un peu en même temps que le CPU, les yeux encore tout collés et il lui faut un petit moment pour qu'il retrouve ses esprits. Heureusement, dès le début, son registre PPUSTATUS est disponible et peut nous servir à attendre que le reste soit opérationnel (soit le temps d'une VBL, au moins).

;; On attend un peu que le PPU se réveille
  BIT PPUSTATUS ; La première lecture passe l'état à zéro
- BIT PPUSTATUS ; On boucle tant que le
  BPL -         ;    PPU n'est pas prêt (VBL suivante)

Note : L'assembleur ASM6 propose des labels locaux sous forme de « - » et de « + ». Le « BPL - » signifie ainsi « saut conditionnel au - le plus près qui est au-dessus ». Ça évite de devoir trouver des noms de label pour chaque boucle. Très pratique à l'usage.

Puis on peut passer à l'initialisation proprement dite, en commençant par mettre à 0 toute la RAM disponible pour le CPU avec le code suivant. Notez l'utilisation du mode d'adressage indexé par X, pour considérer les 2 Ko de RAM comme 8 tableaux de 256 octets.

;; Remise à zéro de toute la RAM
  LDA #0       ; Place 0 dans A
  TAX          ;   et dans X
- STA $0000,X ; Efface l'adresse   0 + X
  STA $0100,X ; Efface l'adresse 256 + X
  STA $0200,X ; Efface l'adresse 512 + X
  STA $0300,X ;   etc.
  STA $0400,X
  STA $0500,X
  STA $0600,X
  STA $0700,X
  INX          ; Incrémente X
  BNE -        ; et boucle tant que X ne revient pas à 0
  ...

Après avoir nettoyé la RAM CPU, on remplit la RAM du PPU avec nos données, comme on l'a vu précédemment :

  ;; Chargement du fond
  LDA PPUSTATUS ; Resynchronisation
  LDA #$20       ;   On copie maintenant
  STA PPUADDR    ;     vers l'adresse $2000
  LDA #$00
  STA PPUADDR
 
  LDX #0
- LDA nametable,X   ; On charge les 256 premiers octets
  STA PPUDATA       ;   depuis notre nametable
  INX
  BNE -
  ...

Évidemment, d'autres boucles de copies ou de mise à zéro suivent, mais je vous laisse découvrir ça en détail par vous-même.

Et la routine RESET finit par réactiver le PPU avant de sauter à une boucle sans fin :

;; Avant de rebrancher le PPU
  LDA #%10010000 ; Réactivation, avec les tuiles de fond en $1000
  STA PPUCTRL
  LDA #%00011110 ; On veut montrer le fond au moins
  STA PPUMASK
  ...

La routine VBL (NMI) ne fait pas grand-chose non plus. Elle se contente de mettre à 1 la variable vbl_flag et d'incrémenter vbl_cnt, ce qui n'a pas une utilité réelle pour l'instant. Cependant, on notera l'obligation de sauvegarder la valeur du registre A (sur la pile ici), au cas où l'interruption vienne interrompre (!) le code de la boucle principale à un moment où le contenu de A est important. On notera également que cette routine d'interruption doit absolument se terminer par l'opcode RTI (ReTurn from Interrupt).

;; La routine VBL
VBL:
  PHA          ; On sauvegarde A sur la pile
  LDA #1       ; On indique à la partie principale
  STA vbl_flag ;   que la VBL a eu lieu
  INC vbl_cnt ; Et on incrémente le compteur de VBL
  PLA          ; Récupération de A
  RTI          ; Fin de routine d'interruption

À la fin de notre programme, on n'oubliera pas les trois vecteurs d'interruption :

;; Les vecteurs du 6502
  ORG $FFFA
  DC.W VBL    ; Appelé à chaque début d'image
  DC.W RESET ; Appelé au lancement
  DC.W $00    ; Inutilisé

Puis on inclura le fichier de 8 Ko contenant les tuiles pour la ROM du PPU. C'est dans ces tuiles que l'on retrouvera les caractères qui sont affichés, on pourrait aussi mettre des graphismes plus rigolos :

  INCBIN "gfx.chr" ; la ROM du PPU

Il existe plein de programmes pour créer ce fichier, décrits dans [3].

Vous pouvez récupérer le fichier hello-world.nes déjà tout compilé sur le GitHub du magazine [5] et l'utiliser dans un émulateur comme FCEUX [6] ou Nestopia [7], par exemple. Mais vous pouvez aussi récupérer le code source et l'assembler vous-même avec la ligne de commande suivante :

$ ./asm6 hello-world.asm hello-world.nes

Cette dernière façon de faire vous permettra de modifier le code et les données afin de mieux comprendre le fonctionnement et l'intérêt de chaque ligne, ce à quoi je vous encourage vivement ! Amusez-vous notamment à modifier la nametable, la palette et les attributs pour changer ce qui est affiché.

4. Un programme plus intéressant

Cela fait beaucoup de lignes de code pour pas grand-chose, me direz-vous. Certes, mais on peut rendre ce programme plus intéressant en ajoutant assez peu de lignes. Pour cela, nous allons utiliser un registre supplémentaire du PPU : PPUSCROLL, auquel on peut accéder depuis le CPU en écrivant à l'adresse $2005.

Ce registre est un peu particulier puisqu'il nécessite deux écritures consécutives pour qu'il soit vraiment modifié, un peu comme dans le cas de PPUADDR. La première écriture modifie le décalage de l'écran en X et la seconde, le décalage en Y. Dans mon exemple, je fais juste un scrolling horizontal, un décalage en X est donc suffisant, mais il faut tout de même écrire le décalage (de 0 pixels) en Y pour que tout soit bien pris en compte. Sans surprise, cela ressemble à ça (à mettre dans la VBL) :

  LDA offset    ; On charge notre décalage
  STA PPUSCROLL ;   qui devient la valeur de scrolling en X
  LDA #0        ; Et on met 0 pour la valeur
  STA PPUSCROLL ;   de scrolling en Y

La variable offset est mise à jour dans la boucle principale de cette manière :

;; Mise à jour de l'offset
  LDA direction ; Si la direction vaut 1
  BNE a_gauche   ; C'est qu'on décale vers la gauche
  CLC            ; Sinon,
  LDA offset     ; On effectue une addition
  ADC #3         ;   de 3 pixels
  STA offset     ;   sur l'offset
  CMP #255       ; Et si on arrive à 255
  BNE mainloop
  INC direction ; ... on change de direction
  JMP mainloop
a_gauche         ; Si on va à gauche,
  SEC            ; Le processus est le même
  LDA offset     ;   dans l'autre sens :
  SBC #3         ;   on soustrait 3
  STA offset
  BNE mainloop   ; Et si on est à 0
  DEC direction ; On rechange de direction

On notera l'utilisation d'une seconde variable, direction qui indique si on est en train d'aller vers la gauche ou vers la droite.

Je vous laisse le soin d'examiner le reste du programme source scrolling.asm afin de voir les (rares) changements qu'il peut y avoir avec le hello-world.asm vue précédemment. On peut voir le résultat de ce programme sur la figure 10. Encore une fois, je vous encourage à modifier ce programme pour changer l'affichage, la vitesse du scrolling ou bien d'autres choses encore.

scrolling-screenshot 0

Figure 10 : Un logo qui défile sur NES !

5. La prochaine fois

La prochaine fois, nous continuerons notre exploration des possibilités du PPU. Nous en profiterons pour ajouter des sprites (des éléments qui peuvent se déplacer sur le décor) et en ajoutant la gestion des joypads, on pourra réaliser notre premier mini jeu sur NES !

Références

[1] https://nesdev.com/

[2] https://wiki.nesdev.com/w/index.php/2A03

[3] https://wiki.nesdev.com/w/index.php/Tools

[4] http://3dscapture.com/NES/asm6.zip

[5] https://github.com/Hackable-magazine/Hackable34

[6] http://www.fceux.com/web/home.html

[7] http://nestopia.sourceforge.net/



Article rédigé par

Par le(s) même(s) auteur(s)

Programmation avec le 6502 : vers des jeux plus évolués

Magazine
Marque
Hackable
Numéro
37
Mois de parution
avril 2021
Spécialité(s)
Résumé

Nous savons à présent comment exploiter les capacités du 6502 et du PPU de la NES afin de faire des jeux, comme le Pac-Man présenté lors du dernier article. J'espère d'ailleurs que certains d'entre vous ont essayé, et sont parvenus à améliorer ce programme, disponible sur le GitHub du magazine. Aujourd'hui, nous allons voir que les cartouches de jeux elles-mêmes peuvent renfermer des trésors d'ingéniosité électronique, permettant d'augmenter les capacités de base de la console.

Programmation avec le 6502 : les sprites de la NES, ou comment coder le jeu Pac-Man

Magazine
Marque
Hackable
Numéro
36
Mois de parution
janvier 2021
Spécialité(s)
Résumé

Dans le précédent article, nous avons commencé à nous familiariser avec la partie graphique de la console NES (Nintendo Entertainment System). Aujourd’hui, nous allons réaliser un véritable jeu, ou du moins nous allons suffisamment le débuter pour qu’il commence à être intéressant.

Programmation avec le 6502 : trigonométrons !

Magazine
Marque
Hackable
Numéro
33
Mois de parution
avril 2020
Spécialité(s)
Résumé

Lors du précédent article, nous avons parcouru les différents modes d'adressage du 6502, ce qui nous a permis d'élaborer quelques algorithmes simples, notamment pour réaliser des additions ou soustractions sur des nombres entiers de plus de 8 bits et même, des multiplications. Aujourd'hui, nous allons continuer dans cette voie en nous intéressant à la division et même aux nombres décimaux (à virgule), ce qui nous permettra de mettre un pied dans le monde effrayant de la trigonométrie !

Les derniers articles Premiums

Les derniers articles Premium

Cryptographie : débuter par la pratique grâce à picoCTF

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’apprentissage de la cryptographie n’est pas toujours évident lorsqu’on souhaite le faire par la pratique. Lorsque l’on débute, il existe cependant des challenges accessibles qui permettent de découvrir ce monde passionnant sans avoir de connaissances mathématiques approfondies en la matière. C’est le cas de picoCTF, qui propose une série d’épreuves en cryptographie avec une difficulté progressive et à destination des débutants !

Game & Watch : utilisons judicieusement la mémoire

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Au terme de l'article précédent [1] concernant la transformation de la console Nintendo Game & Watch en plateforme de développement, nous nous sommes heurtés à un problème : les 128 Ko de flash intégrés au microcontrôleur STM32 sont une ressource précieuse, car en quantité réduite. Mais heureusement pour nous, le STM32H7B0 dispose d'une mémoire vive de taille conséquente (~ 1,2 Mo) et se trouve être connecté à une flash externe QSPI offrant autant d'espace. Pour pouvoir développer des codes plus étoffés, nous devons apprendre à utiliser ces deux ressources.

Raspberry Pi Pico : PIO, DMA et mémoire flash

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Le microcontrôleur RP2040 équipant la Pico est une petite merveille et malgré l'absence de connectivité wifi ou Bluetooth, l'étendue des fonctionnalités intégrées reste très impressionnante. Nous avons abordé le sujet du sous-système PIO dans un précédent article [1], mais celui-ci n'était qu'une découverte de la fonctionnalité. Il est temps à présent de pousser plus loin nos expérimentations en mêlant plusieurs ressources à notre disposition : PIO, DMA et accès à la flash QSPI.

Les listes de lecture

7 article(s) - ajoutée le 01/07/2020
La SDR permet désormais de toucher du doigt un domaine qui était jusqu'alors inaccessible : la réception et l'interprétation de signaux venus de l'espace. Découvrez ici différentes techniques utilisables, de la plus simple à la plus avancée...
8 article(s) - ajoutée le 01/07/2020
Au-delà de l'aspect nostalgique, le rétrocomputing est l'opportunité unique de renouer avec les concepts de base dans leur plus simple expression. Vous trouverez ici quelques-unes des technologies qui ont fait de l'informatique ce qu'elle est aujourd'hui.
9 article(s) - ajoutée le 01/07/2020
S'initier à la SDR est une activité financièrement très accessible, mais devant l'offre matérielle il est parfois difficile de faire ses premiers pas. Découvrez ici les options à votre disposition et les bases pour aborder cette thématique sereinement.
Voir les 23 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous