Ce projet n'est pas terminé. Mises à jour à venir!
Toutefois, une version beta est déjà disponible. Pourquoi ne pas l'essayer?
Écrire mon propre émulateur est un projet qui m'a longtemps tenté, mais pour l'entreprendre il me manquait
un but précis ou une raison pour ne pas simplement me servir ce qui existe déjà, jusqu'au jour où j'ai décidé
de tenter l'expérience de faire fonctionner un petit jeu
pour PC sur un système Android (une vieille console OUYA en fait).
Le jeu en question: RATillery,
un jeu tout simple que j'ai écrit en assembleur 8086/8088.
Je tiens à ce que la version Android du jeu utilise le même code en assembleur que la version originale pour DOS,
simplement car c'est exceptionnel et fascinant. Il me faut donc un émulateur capable de faire fonctionner
le jeu.
Un choix évident serait DOSBox bien sûr, mais j'ai écarté cette option pour
quelques raisons[1]:
Je souhaite maîtriser et comprendre l'ensemble du système. Rien de tel que de tout faire soi-même pour cela!
Je prévois augmenter le jeu en utilisant des éléments standard
de l'interface utilisateur du système. Je vais tenter de remplacer les menus fonctionnant avec les lettres
du clavier par des boutons standard. Peut-être même utiliser des Spinners pour choisir l'angle et
la force de tir facilement sur l'écran tactile. Pour se faire, l'émulateur injectera du code
dans le jeu pour remplacer certaines routines par des appels système spéciaux.
L'utilisation d'un gros morceau que je n'ai pas créé (DOSBox) beaucoup trop complet, puissant et complexe
pour le peu de ressources qu'utilise mon jeu serait sans doute un plus gros obstacle que d'écrire mon
propre émulateur.
Je veux que l'ensemble de l'oeuvre soit ma propriété, afin de pouvoir faire comme bon me semble. Ainsi, si je souhaite vendre un jeu utilisant cet émulateur, il n'y aura pas de complications à cause de
licences.
[1] oui, oui, d'accord, ce sont des excuses. Je veux simplement coder un émulateur.
Première partie: Android et Java
Java semble être l'option la plus facile pour débuter sur Android en raison des tonnes d'exemples et de l'immense
collection de questions et réponses sur le web, notamment sur Stack Overflow. Puisque le développement sur Android
est nouveau pour moi, je ne souhaite pas pour le moment essayer de nouveaux langages ou une combinaison de langages. Je
vais donc, même si j'ai le sentiment que c'est un mauvais choix pour un émulateur, tenter de tout faire en Java.
Je n'ai jamais beaucoup aimé Java et j'ai passé les 18 dernières années à me moquer des éléments du langage qui
protègent (trop à mon avis) le programmeur contre lui même au détriment de la performance. Il vaut mieux que je n'élabore
pas ici... J'ai certainement très hâte de voir si émuler ce microprocesseur 16 bit d'Intel ne tournant qu'à 4.77MHz sera
possible, ou si ce sera trop lent, et quel type d'optimisations seront possibles le cas échéant.
J'ai suivi quelques tutoriels pour me familiariser avec Android, découvrir les nouveautés du langage
Java depuis la dernière fois que j'y avais porté une attention sérieuse (Des exemples: Generics, For-Each Loops, Varargs),
et apprendre à travailler dans un IDE, en l'occurrence Android Studio. (Je développe normalement avec vim et j'écris mes
propres Makefiles. Mais l'adaptation a été plus facile que je m'y attendais)
Quand j'ai eu fini de faire des interfaces graphiques avec des boutons « Hello World » j'ai plongé dans l'implémentation
de l'émulateur.
Deuxième partie: Architecture générale
J'ai créé une classe pour l'unité centrale nommée Cpu8088, et quelques interfaces de support: Une pour la mémoire (Memory), une pour
les appels système par l'instruction INT (Int) et une pour les ports d'entrée sortie (IOPort).
La création d'une instance Cpu est toute simple:
mCPU = new Cpu8088();
Zones mémoires
Impossible de faire quoi que ce soit sans avoir de mémoire! Il faut fournir un nombre d'objets de type Memory au Cpu8088.
J'ai créé l'interface suivante:
public interface Memory {
boolean isAddressInRange(int address);
void write8(int address, int b);
void write16(int address, int w);
int read8(int address);
int read16(int address);
}
L'objet MemoryBlock implémente cette interface et utilise un tableau d'octets pour la mémoire comme tel. Je crée
donc quelques instances pour couvrir les zones mémoires essentielles à l'exécution de RATillery.
MemoryBlock stack = new MemoryBlock(0x400, MemoryBlock.SIZE_64K);
MemoryBlock program = new MemoryBlock(0x10000, MemoryBlock.SIZE_64K * 4);
cga_vram = new MemoryBlock(Cpu8088.getLinearAddress(0xB800, 0), MemoryBlock.SIZE_64K/2);
MemoryBlock ivt = new MemoryBlock(0, 0x400);
mCPU.addMemoryRange(program);
mCPU.addMemoryRange(cga_vram);
mCPU.addMemoryRange(stack);
mCPU.addMemoryRange(ivt);
/* Charge l'exécutable du jeu (gametga.com) dans le bloc de mémoire 'program' à partir de
* l'adresse 0x100. (Les exécutables .COM ont une origine de 0x100). */
program.load(this.getResources().openRawResource(R.raw.gametga), 0x100);
Il y a quatre blocs de mémoire ci-dessus:
Un pour le code du jeu. L'exécutable du jeu (un fichier .com) est stocké comme ressource de type raw dans le projet.
Un autre bloc pour la mémoire vidéo. Le jeu dans l'émulateur accédera à cette zone mémoire normalement, comme
s'il s'agissait de la mémoire d'une carte vidéo. Le code d'affichage Android que je mettrai en place n'aura qu'à la
lire et convertir le contenu vidéo en format Tandy 16 couleurs vers son format natif.
Encore un autre bloc pour la pile.
Et finalement, un petit block pour le vecteur d'interruption. (RATillery y accède directement pour installer le gestionnaire
d'interruption s'occupant du son.
Services du BIOS
Mon jeu fait appel à des services du BIOS par l'instruction INT.
Il faut donc fournir quelques implémentations
de l'interface Int au CPU.
L'interface Int comporte deux méthodes: Une pour gérer l'interruption, une autre pour déclarer le numéro de l'interruption
prise en charge.
public interface Int {
public void handle(Cpu8088 cpu);
public int getNumber();
}
Au contraire d'un PC où l'instruction INT provoque
un saut vers le code correspondant dans le BIOS, mon émulateur traite la demande en Java en regardant l'état
des registres, et fait les changements à la mémoire ou aux registres avant de redonner le contrôle au programme.
(note: Cette approche n'est pas rare en émulation)
En général, lors de l'appel d'un service par l'instruction Int, le registre AH contient le numéro d'une sous-fonction.
Il n'est pas nécessaire d'implémenter toutes les sous-fonctions, ni même de le faire très correctement. Par exemple,
pour Int10,AH=0x0 qui choisis le mode vidéo, je ne fais que retourner le mode demandé (c'est donc un succès assuré).
Voici le code pour Int10, les fonctions de la carte vidéo:
public class Int10 implements Int, Constants {
private int current_mode = 0x03; // 80x25 color
@Override
public void handle(Cpu8088 cpu) {
switch(cpu.AX >> 8)
{
case 0x0:
current_mode = cpu.AX & 0xff;
Log.d(LOG, "int10: Set video mode to 0x" + Integer.toHexString(current_mode));
break;
case 0xF: // Get Video State
Log.d(LOG, "int10: Get video state");
// AH : number of screen columns
// AL : mode currently set
// BH : current display page
cpu.setAH(80);
cpu.setAL(current_mode);
cpu.setBH(0);
break;
case 0xB: // Set palette
Log.d(LOG, "int10: Set palette (ignored)");
break;
default:
cpu.halt("Int10: subservice 0x" + Integer.toHexString(cpu.AX >> 8));
}
}
@Override
public int getNumber() {
return 0x10;
}
Pour RATillery, les gestionnaires suivants sont requis:
Service 10h (video): Utilisé pour entrer et sortir du mode graphique
Service 16h (clavier): Utilisé pour recevoir des événements de touche du clavier.
Service 1Ah (chronomètre): Utilisé pour obtenir l'heure qui sert de valeur initiale au générateur pseudo-aléatoire.
Et du coup:
mCPU.addIntHandler(new Int1a()); // Time
mCPU.addIntHandler(new Int10()); // Video
keyboardInt = new Int16();
mCPU.addIntHandler(keyboardInt);
(Int16 est gardé dans une variable pour pouvoir injecter des événements de touche de clavier en appelant une méthode.)
Ports d'E/S
Le jeu utilise également quelques ports d'E/S (par les instructions IN et OUT). Il est donc nécessaire de fournir
quelques objets implémentant l'interface IOPort:
public interface IOPort {
public boolean isPortHandled(int port);
public int in8(int port);
public int in16(int port);
public void out8(int port, int value);
public void out16(int port, int value);
}
Les ports utilisés par RATillery sont:
Port 0x3DA: Le jeu lit ce port pour se synchroniser au rafraîchissement vertical de l'écran. (Le jeu s'en sert pour
avoir une vitesse constante)
Ports 0x40-0x5F, 0x61: Le jeu contrôle le haut-parleur et configure la fréquence à laquelle le gestionnaire d'interruption
s'occupant de la musique est exécuté avec quelques écritures vers ces ports.
Et donc:
vcard_ports = new VideoCardPorts();
mCPU.addIOPort(vcard_ports);
mCPU.addIOPort(new PITPorts());
Troisième partie: Implémenter le CPU
L'object Cpu8088 contient des variables qui correspondent à chacun des registres du 8088, des constantes pour les flags, des
listes pour les zones mémoires, les gestionnaires d'interruption et les port d'E/S.
Cpu8088 contient également la méthode void runOneInstruction() qui se charge de lire les instructions
une par une. Il s'agit simplement d'un très long swich/case. Voici quelques extraits:
switch (opcode)
{
case 0x50: push16(AX); break; // PUSH AX
case 0x58: AX = pop16(); break; // POP AX
case 0x40: AX = alu.add(AX, 1, ALU.WORDMODE); break; // INC AX
case 0x34: // XOR AL,IMMED8
immed8 = getInstructionByte();
setAL(alu.xor(AX & 0xff, immed8, ALU.BYTEMODE));
break;
case 0xAC: // LODSB
setAL(read8(getLinearAddress(DS_CUR, SI)));
if ((FLAGS & DF) == DF) {
SI = (SI-1) & 0xffff;
} else {
SI = (SI+1) & 0xffff;
}
break;
}
Hmm, c'est un peu long. Ne faisons qu'implémenter le nécessaire... - Échec!
Avec le livre « 8086/8088 User's Manual - Programmer's and Hardware Reference » en main, j'ai commencé à implémenter les instructions
à partir de 0, en ordre. Une dizaine d'instructions plus tard, j'ai pensé que je n'utilisais probablement qu'un
nombre limité d'instructions. Il serait donc plus rapide d'implémenter les instructions manquantes.
J'ai fait en sorte que l'émulateur arrête lorsqu'il rencontre une instruction inconnue avec un message
d'erreur indiquant le numéro.
Il s'est ensuite passé un certain nombre de choses, à peu près dans cet ordre:
- Génial, je peux voir le programme appeler des sous-routines et le retour fonctionner!
- Il y a quand même beaucoup d'instructions manquantes...
- Oh, je dois implémenter le préfixe de répétition REP. Utilisons la récursion.
- Hmm, il y a quand même vraiment beaucoup d'instructions manquantes...
- Oh là là, c'est bien toutes ces instructions qui opèrent au choix sur la mémoire ou sur des
registres, mais il faut tout décortiquer..
- C'est encore pire. Il peut y avoir un déplacement codé sur 8 ou 16 bits qu'il faut prendre en compte...
- Mais les instructions 0x80,0x81,0x83 sont infernales! Elles sont suivies d'une deuxième octet pour
choisir l'opération.
Lorsque j'ai compris l'ampleur de ce que j'avais entrepris, il était trop tard pour reculer. J'avais trop écrit
de code pour arrêter mon projet. Ce serait du gaspillage. Mais c'était loin d'être terminé.
Après avoir persévéré un bon moment, l'émulateur a cessé de rencontrer des instructions inconnues. Mais
RATillery était coincé dans une boucle qui attend qu'un bit du port d'E/S 0x3DA change d'état. Rapidement,
j'ai fais en sorte que ce bit alterne à chaque lecture en modifiant la classe VideoCardPorts. Ce n'est pas « correct »,
mais ça allait pour le moment et permettait à l'exécution de continuer à la rencontre d'une autre tonne
d'instructions, de sous-instructions et de modes d'adressages que je n'ai pas encore implémentés...
Un premier bug dans l'émulateur à résoudre
Lorsqu'on fait de la programmation, il arrive à l'occasion qu'un problème semble insoluble. Et quand
cela fait trop longtemps que nous cherchons l'explication, il arrive que nous commencions à échafauder
toutes sortes de théories douteuses. C'est ce que
je nomme commencer à dérailler. J'ai vu des gens, et j'ai moi-même déraillé plusieurs fois. Des
boucs émissaires habitués à ces séances de « déraillement » sont le microprocesseur, la mémoire... le matériel
quoi! Mais il est extrrrrêêêmement rare que ces éléments soient en cause, et la faute revient
généralement au programmeur.
Mais cette fois je suis en droit total de soupçonner le matériel. Car le matériel
ici, c'est l'émulateur, c'est du logiciel pur! Et comme je sais que RATillery fonctionne parfaitement
bien dans DOSBox et sur toutes les machines physiques sur lesquelles je l'ai essayé, j'ai forcément raison.
Ma première difficulté avec le « matériel »:
- Le code est pris dans une boucle infinie. Je regarde le source de RATillery pour comprendre... Je suis l'exécution
dans l'émulateur une ligne à la fois très attentivement... Bon. Certains
sauts conditionnels ne fonctionnent pas correctement car j'ai mal implémenté les flags
pour certaines opérations...
Encore quelques instructions (et autres trucs) manquant et ça y est, RATillery semble être rendu
à l'écran d'accueil. Le tableau de bytes correspondant à la mémoire vidéo devrait contenir quelque chose d'intéressant à présent!
Quatrième partie: Une première image
J'ai commencé par créer un Thread qui se charge d'appeler la méthode Cpu8088.runOneInstrction() en boucle. L'émulation
roulera aussi vite qu'elle peut pour le moment (il faudra changer cela plus tard).
Côté interface usager, j'ai simplement ajouté un ImageView à
une Activité vide. Dans la méthode onCreate de l'activité, je récupère l'ImageView et crée un Bitmap dont la taille correspond
au mode vidéo du jeu (320x200). Je prépare également un tableau contenant les 16 couleurs qui seront utilisées.
Afin de mettre à jour le Bitmap (et ultimement, l'ImageView), j'utilise un handler.postDelayed() pour exécuter
régulièrement la méthode syncVram() qui utilise le tableau de couleurs créé ci-dessus pour faire la conversion des pixels contenus dans le MemoryBlock
correspondant à la mémoire vidéo pour le jeu vers le Bitmap. La méthode setPixels() de Bitmap est d'abord utilisée pour modifier le Bitmap. Ensuite, l'ImageView
est mis à jour en passant ce bitmap lors d'un appel de ImageView.setImageBitmap().
J'ai ainsi obtenu une première image:
Hourra! Je suis heureux d'avoir enfin une première image. Même s'il semble y avoir plusieurs problèmes, il s'agit d'une étape importante et encourageante.
Cinquième partie: Mémoire Tandy 16 couleurs vers Bitmap
Le mode 320x200x16 (16-Color Medium-Resolution)
Pour le moment, je vise à supporter la version Tandy (16 couleurs) de RATillery. Ce mode vidéo
utilise 4 bits par pixels, alors chaque octet contient deux pixels:
Premier pixel
Deuxième pixel
7
6
5
4
3
2
1
0
I
R
G
B
I
R
G
B
Chaque ligne de l'écran qui fait 320x200 occupe donc 160 octets. Cependant les lignes ne sont pas consécutives
en mémoire.
Elles sont séparées en 4 blocs:
Lignes
Adresse
0, 4, 8 ... 196
0x0000 - 0x1F3F
1, 5, 9 ... 197
0x2000 - 0x3F3F
2, 6, 10 ... 198
0x4000 - 0x5F3F
3, 7, 11 ... 199
0x6000 - 0x7F3F
Une première tentative
J'ai d'abord fait quelque chose comme ceci pour copier et convertir les pixels stockés dans le tableau
de bytes vers le Bitmap:
byte[] tandy16 = ratillery.getVRAM(); // Retourne le byte[] array de la mémoire vidéo
for (y=0; y < 200; y++) {
for (x=0; x < 320; x+=2) {
pixel1 = tandy16[y * 160+ x/2] >> 4;
pixel2 = tandy16[y * 160 * x/2 + 1] & 0xf;
mScreenBitmap.putPixel( x, y, clut[pixel1] );
mScreenBitmap.putPixel( x+1, y, clut[pixel2] );
}
}
(Oui, appeler putPixel en boucle pour faire ceci est un excellent moyen de saboter la performance. À éviter!)
Si vous êtes attentif, vous remarquerez que le code ci-dessus ignore le fait que les lignes ne se suivent pas. Cela explique
en partie pourquoi la première image que j'ai obtenue présente 4 bandes. Ce sont comme 4 écrans compressés au quart de
leur véritable hauteur disposés verticalement. J'ignore où j'avais la tête!
La bonne approche
J'ai modifié le code pour une approche ligne par ligne qui prépare maintenant un tableau de int[] pour le passer
ensuite à la méthode putPixels() de Bitmap. C'est sans doute plus rapide, il faudra que je chronomètre le tout
et fasse quelques expériences. Il y a peut-être du temps à gagner... Par exemple, est-ce qu'en Java il est
utile de manuellement sortir les calculs répétitifs (y*160 par exemple) de l'intérieur des boucles?
Est qu'il y a un avantage à remplacer les multiplications et divisions par des shifts lorsque possible? Est-ce
que le compilateur comprend que les accès au tableau tandy16[] ne sortiront jamais des bornes et qu'il n'est
pas nécessaire de revérifier à chaque accès? J'ai hâte d'être rendu à l'étape de répondre à ces questions.
Voici le code pour le moment:
private int rowbuf[] = new int[320];
...
private void doScanline(byte tandy16[], int src_offset, int screen_y)
{
int x;
for (x=0; x<320; x+=2) {
int pixelpair = tandy16[src_offset+(x/2)] &0xff;
rowbuf[x] = clut[pixelpair >> 4];
rowbuf[x+1] = clut[pixelpair & 0xf];
}
mScreenBitmap.setPixels(rowbuf, 0, 320, 0, screen_y, 320, 1);
}
private void syncVram() {
int y;
MemoryBlock vram = ratillery.getVRAM();
final byte[] data = vram.getRawBytes();
for (y=0; y<50; y++) {
doScanline(data, 0 + y * 160, y * 4);
}
for (y=0; y<50; y++) {
doScanline(data, 0x2000 + y * 160, y*4 + 1);
}
for (y=0; y<50; y++) {
doScanline(data, 0x4000 + y * 160, y*4 + 2);
}
for (y=0; y<50; y++) {
doScanline(data, 0x6000 + y * 160, y*4 + 3);
}
}
Ce nouveau code a donné un meilleur résultat. Voici la première et cette deuxième image pour comparaison:
Première image
Seconde image
On se rapproche. Le texte du menu est au bon endroit, mais il est illisible. Étrange... Mais le problème
était à nouveau dans l'implémentation du CPU.
Sixième partie: Erreurs d'implémentation
Changement de programme: Utilisons un test simple
J'ai cherché à comprendre ce qui n'allait pas, mais utiliser RATillery comme test compliquait les choses et brouillait les pistes.
J'ai donc changé pour un exécutable de test qui ne fait qu'afficher quelques boîtes de couleurs et une ligne de texte.
Ce programme de test, lorsqu'il tourne dans un émulateur qui fonctionne bien, présente un écran comme celui-ci:
Le test simple dans DOSBox
Mais dans mon émulateur, j'ai d'abord eu un écran tout noir. La raison était dans les logs du système:
Ça y est, les instructions non implémentées, ça recommence...
Cette fois, il s'agit d'une des versions du OU exclusif (XOR). Celle qui prend une valeur immédiate
d'un octet...
Après avoir ajouté encore quelques instructions, le test s'affiche au complet, mais incorrectement. Après quelques
corrections mineures, le texte est presque lisible.
Test simple (résultat incorrect)
Anomalie 1: Il y des lignes en trop dans le texte!
Ceci n'est PAS du Standard Galactic Alphabet:
Tout comme RATillery, ce programme de test utilise des caractères de 8x8. Or, il me semble pouvoir déceler 10 lignes!:
Il m'a fallu un très long moment pour trouver la cause (toute simple évidemment) du problème, et un tout petit instant pour le corriger.
Quel idiot! Le manuel est pourtant clair. L'instruction LOOP décrémente CX avant de vérifier s'il est rendu à zéro!
Eh oui, certaines boucles faisaient un tour de trop...
Anomalie 2: Pixels manquant dans le cadre
J'ai aussi cherché assez longtemps à comprendre pourquoi quelques pixels manquent à l'appel dans le coin supérieur gauche:
Le problème était mon implémentation de l'instruction STOSB. Pouvez-vous identifier l'erreur?
STOSB écrit le contenu du registre AL à l'adresse ES:DI. DI doit ensuite être incrémenté ou décrémenté. Pourquoi
je touche à SI ici? J'attribue ceci à la fatigue et la fonction copier/coller.
Test OK
De retour à RATillery pour voir:
Déception! J'espérais que tout serait réglé...
...mais ce n'est pas le cas, semble-t-il. L'arrière-plan est stocké
dans un format compressé et le code de décompression n'a pas l'air très content.. Par contre le texte
apparaît bien comme il faut, et l'animation de feu fonctionne.
Je dois plonger pour plusieurs heures dans le code du décompresseur pour trouver des indices sur quelle peut bien être
mon erreur cette fois....
Ah ha! Mon implémentation du préfixe REP est incorrecte lorsque CX = 0.
Il se trouve que lorsque CX=0, l'instruction suivante (MOVSB, par exemple) était quand
même exécutée une fois.
Une fois corrigé:
Victoire! Une autre étape importante de franchie: Le jeu affiche enfin l'écran titre correctement!
Prochaine étape: Créer un système pour simuler des événements de touche au clavier afin de lancer une partie.
Septième partie: Support du clavier
Enfin, plutôt que de simplement noter et corriger les problèmes qu'on rencontre par la simple exécution du jeu,
il est temps d'interagir avec le jeu pour causer les problèmes!
Le jeu utilise le service clavier du BIOS implémenté par la classe Int16. J'ai mis en place une queue d'événement
d'une seule touche (à revoir plus tard):
public class Int16 implements Int {
// TODO : Need a FIFO
private boolean hasKeystroke = false;
private int scancode;
public void injectKeyStroke(int scancode) {
this.scancode = scancode;
hasKeystroke = true;
}
@Override
public void handle(Cpu8088 cpu) {
switch(cpu.AX >> 8)
{
case 1: // Get keyboard status
if (hasKeystroke) {
cpu.AX = scancode;
cpu.FLAGS &= ~cpu.ZF;
} else {
cpu.AX = 0; // scancode (none)
cpu.FLAGS |= cpu.ZF;
}
break;
case 0: // Note: This is incorrect as it does not block.
cpu.AX = scancode;
hasKeystroke = false;
break;
default:
Log.e(LOG, String.format("int16: service AH=0x%02x not implemented", cpu.AX >> 8));
}
}
...
}
Interface usager de fortune
Pour la fin, j'ai quelque chose de mieux en tête que de simplement placer des boutons au
bas de l'écran. Mais ceci viendra plus tard. Pour le moment, je souhaite pouvoir
jouer une partie dès que possible.
J'ai donc mis en place les boutons nécessaires, et fait en sorte que chacun
fasse appel à la méthode injectKeyStroke() avec le caractère ASCII
de la touche correspondante en argument. Les scancodes sont normalement
codés sur 16 bits. Les 8 bits du bas correspondent au code ASCII, et les 8 bits
du haut sont un identifiant physique de la touche sur le clavier. Je sais
que RATillery ne tiens compte que des 8 bits du bas, alors cette approche
convient.
Support d'un clavier physique
Un peu plus tard, je voulais accéder aux écrans d'histoire et de crédits, ce qui nécessite
les touches S et C. Plutôt que d'ajouter deux autres boutons, j'ai branché un clavier
USB à la console OUYA que j'utilise pour le développement. Pour transférer les
événements de touche, il a simplement fallu réimplémenter la méthode onKeyUp
de l'activité:
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
int unichar = event.getUnicodeChar();
if (unichar >= 32 && unichar <= 128) {
ratillery.pressAsciiKey((char)event.getUnicodeChar());
}
// extra code for ESC, ENTER and BACKSPACE keys not shown
return true;
}
Huitième partie: Bugs en jeu
Alors j'ai lancé un partie, et ça fonctionnait presque bien! J'ai cependant rencontré quelques problèmes.
1 - C'est toujours le tour du premier joueur
Cette sous-routine s'occupe de faire alterner la valeur de [player_turn] entre 0 et 1:
togglePlayerTurn:
push ax
mov al, [player_turn]
not al
and al, 1
mov [player_turn], al
pop ax
ret
Note: Oui... J'étais un débutant en assembleur x86 lorsque j'ai créé RATillery, et je pensais encore comme si j'écrivais de l'assembleur pour microcontrôlleur AVR où il faut toujours charger les valeurs à manipuler dans des registres d'abord. De nos jours j'écrirais simplement NOT byte [player_turn], probablement dans un macro...
Le code ci-dessus fonctionne bien, malgré sa complexité superflue. Alors pourquoi le tour des joueurs n'alterne pas? C'est simplement que l'instruction
NOT n'était pas implémentée. À cause d'un break; manquant dans un switch/case, l'émulateur ne s'en plaignait pas et faisait plutôt une tout autre opération.
2 - Impossible d'abandonner
Appuyer sur ESCAPE, puis confirmer l'intention d'abandonner la partie avec la touche Y ne fonctionnait pas. Le jeu
continuait tranquillement, comme si de rien n'était. Qu'est-ce qui n'allait pas cette fois? J'ai rapidement suspecté
une erreur relative aux flags.
Dans RATillery, il y a une sous-routine nommée askYesNoQuestion. Elle retourne 0 dans CX si la réponse
de l'usager était non, et elle retourne 1 dans CX si la réponse était oui. Le jeu fait appel à cette
sous-routine pour vérifier si réellement on souhaite mettre fin à la partie. Au retour, une valeur de 0xffff est additionnée
à CX, afin de mettre le flag de carry à 1 si la réponse était oui. (Le flag est testé plus tard alors que la valeur de CX est perdue)
À l'aide d'un breakpoint placé immédiatement après cette addition, j'ai pu voir que CX = 0. C'est normal, puisque 1 et 0xffff
donnent 0 sous 16 bits. Le zero flag était bien actif (puisque le résultat est 0) mais le carry flag,
représentant le 17e bit, était à zéro! Il devait pourtant être à 1. Pourquoi?
L'addition est faite avec le code suivant:
AND CX, 0xFFFF
Une fois assemblé, cela donne 83 C1 FF. L'instruction 83 prend une valeur immédiate de 8 bit, l'étend sur 16-bit (avec extension
de signe) avant d'opérer sur un emplacement mémoire ou un registre de 16 bits. En somme, 0xFF devient 0xFFFF (ou -1).
Comme Java n'a pas de types non signés, j'utilise des entiers (int) de 32-bit partout. Il se
trouve que la manière dont je faisais l'extension de signe donnait un résultat
de 32 bits. L'entier tenant la valeur étendue contenait donc une valeur de
-1 (0xffffffff) plutôt que 0xffff.
Cela ne devrait pas être un problème, puisque les 16 bits du bas sont les mêmes. Par ailleurs, sous 16 bits, 1 et 0xffff donnent
le même résultat que 1 et -1. Mais cela brise mon code qui décide quoi faire avec le carry flag:
int add16(int a, int b) {
int result = a+b;
if (r > 0xffff) {
// set CF flag here
} else {
// clear CF flag here
}
// Code for other flags (OF, SF, ZF) not shown
return result & 0xffff;
}
À plusieurs endroits, mon code s'attend à travailler uniquement avec des valeurs
positives. J'ai donc ajouté un AND 0xFFFF sur la valeur obtenue après l'étape d'extension de signe. Le
problème est alors disparu.
3 - L'écran des crédits s'affiche partiellement
L'affichage s'arrêtait en plein dans mon prénom! Je me suis tout de suite douté que cela devait être lié
à la lettre ë. Il s'agissait encore d'une instruction non implémentée. (Cette fois, c'était SUB REG8/MEM8, IMMED8).
Neuvième partie: Optimisations pour accélérer l'émulation
Les effets de transition entre les écrans du jeu me semblaient un peu lents, et j'en ai déduit que l'émulation
du Cpu ne devait pas être assez rapide. J'ai donc décidé de faire quelques efforts d'optimisation.
Le thread d'émulation du CPU fonctionne par bloc de 250 instructions. Le temps qui passe est surveillé
avec des appels à System.nanoTime(). Une fois 15.5ms passées,
j'ai fait en sorte que le bit indiquant le rafraîchissement vertical
de l'écran dans le port d'E/S 3DA indique que le rafraîchissement est
en cours. La logique du jeu et l'animation s'y synchronisent.
J'ai décidé d'utiliser l'écran titre comme sujet de test, puisque plusieurs choses s'y passent:
Il y a du dessin vers l'écran pour l'animation 32x32 du feu, le registre 3DA de la
carte vidéo est lu à répétition pour surveiller le début du rafraîchissement, et le
service clavier du BIOS est sollicité pour détecter les touches.
Afin de mesurer la performance, j'ai mis en place du code qui compte combien d'instructions
sont exécutées dans une fenêtre d'une seconde. Une moyenne de 15 mesures est employée
pour que la valeur d'instructions par seconde (IPS) affichée à l'écran soit plus stable.
Bon, alors mesure initiale de 280000. Voyons ce que je peux faire.
J'ai commencé par désactiver l'option Debuggable dans built type settings. L'indice IPS a monté un peu, soit 278000. Une
très petite différence, ce changement n'en valait pas la peine.
J'ai ensuite pensé: L'écran titre accède très souvent au port 3DA. Les objects prenant en charge les accès aux ports d'E/S (IOPort) sont
stockés dans un ArrayList. Pour chaque accès, il faut parcourir cette liste et appeler la méthode isPortHandled(int)
sur chaque élément jusqu'à ce que le bon soit trouvé. Ceci représente beaucoup d'appels... Le nombre maximum
de ports est 65536. Pourquoi ne pas utiliser un tableau? Dans notre monde de super calculateurs, un tableau de 65000 entrées ce n'est rien
n'est-ce pas? J'ai donc fait la modification.
Tableau pour les IOPorts: IPS maintenant à 317000. Pas mal, 20% plus rapide. À présent, quoi d'autre?
Je me suis demandé, quelles sont les autres pièces auxquels les accès sont courants et répétés? Mais la mémoire vive bien sûr! Et comme
pour les IOPorts, il y a un ArrayList d'objets à parcourir afin de trouver preneur pour gérer l'accès à chaque adresse. Est-ce
possible d'utiliser un tableau ici aussi? Le Cpu 8088 n'a qu'un bus de 20 bits, cela veut donc dire qu'il peut accéder
un maximum de seulement 1 mégaoctet. Si les références d'objet Java sont des pointeurs de 64 bit, cette table occupera
8 mégaoctets. J'ai accepté ce compromis et appliqué cette idée.
Memory memoryZones[] = new memoryZones[0x100000];
L'index 0 est pour l'adresse 0, l'index 1 est pour l'adresse 1, et ainsi de suite. La méthode Cpu8088.addMemoryRange(Memory m)
doit à présent copier la référence m vers les positions appropriées dans le tableau. Ensuite, lorsqu'il
faut accéder à une adresse particulière, il suffit de faire memoryZones[address].
Tableau pour la mémoire: IPS maintenant à 1384000. Excellent, un gain instantané de 400% !
Est-ce qu'il y avait encore des gains possibles? Oui! À plusieurs endroits, une méthode était utilisée pour chercher et retourner
le bon objet Mémoire pour une adresse donnée. Suite à l'introduction du tableau, cette méthode, qui auparavant devait parcourir un ArrayList, ne faisait plus
que simplement retourner memoryZones[address] à l'appelant.
La classe Cpu8088 possède des méthodes pour accéder à la mémoire, comme celle-ci:
private Memory getMemForAddress(int address) {
return memoryZones[address];
}
public int read16(int address) {
return getMemForAddress(address).read16(address);
}
Il me semblait que la méthode getMemForAddress(), toute simple, mériterait d'être
"inlined" (intégrée dans l'appelant, de sorte qu'il n'y a plus de pénalité d'appel). Comme
j'ignorais si je pouvais me fier au compilateur Java ou à la machine virtuelle pour prendre
la décision de le faire, je m'en suis chargé manuellement. J'ai donc retiré la méthode
getMemForAddress() et réécrit les méthodes d'accès à la mémoire pour qu'elles
utilisent le tableau. Par exemple, voici la version réécrite de la fonction ci-dessus:
public int read16(int address) {
return memoryZones[address].read16(address);
}
(Il y a read8, read16, write8 et write16, en version acceptant une adresse linéaire et une paire segment:offset)
Et ça alors! Avec l'inlining, l'indice d'IPS est monté à 2003000!
Pas mal, l'émulation est un peu plus de 7 fois plus rapide qu'au début de l'exercice. Mais pourquoi s'arrêter
là? Il y a encore de l'inlining qui serait possible, mais j'ai décidé de conserver read8/16 et
write8/16 car ces méthodes sont pratiques et utilisées à plusieurs endroits. Mais je peux
faire une exception là où ça compte.
La classe Cpu8088 a quelques méthodes utilisées pour lire depuis le flux d'instructions. C'est à dire, lire
à l'adresse CS:IP, puis incrémenter IP:
getInstructionByte()
getInstructionWord()
getInstrcutionInt16()
getInstructionSX()
Ces méthodes sont constamment appelées par Cpu8088 pour lire une instruction (getInstructionByte()), et ensuite,
selon l'instruction, lire un nombre d'octets en plus selon le cas (registres source/destination,
adresses directes, valeurs immédiates, déplacements...).
Par exemple, cette méthode:
public int getInstructionWord() {
int b = read16(CS, IP);
IP = (IP + 2) & 0xFFFF;
return b;
}
réécrite ainsi:
public int getInstructionWord() {
int b = memoryZones[(CS<<4)+IP].read16(CX, IP);
IP = (IP + 2) & 0xFFFF;
return b;
}
Pas mal, IPS à 2227600
Je ne suis pas prêt à faire de l'inlining pour les méthodes getInstructionX(), le changement
serait trop invasif. À un certain point, il faut aussi penser à la facilité de maintenance et
à la clarté du code. Mais j'ai tout de même inliné l'appel de getInstructionByte()
qui a lieu avant le long switch/case pour les instructions, puisque je sais qu'il se fait
appeler très souvent (plus de 2 millions de fois par seconde en ce moment).
L'effet: On roule maintenant à 2599800 IPS
Si vous avez lu jusqu'ici, vous saisissez sûrement. J'ai continué à faire un peu d'inlining, ça et là
où je jugeais cela acceptable. Ainsi, un peu plus tard...
Résultat final: 2700000 instructions par seconde.
Ceci est 9.64 fois plus rapide qu'au début. J'ai décidé d'arrêter là, car il est temps que je
m'intéresse à d'autres aspects du projet, comme l'expérience usager.
Des idées pour plus tard?
Il y a plusieurs autres optimisations que je pourrais essayer. Un changement qui
permettrait d'augmenter la performance encore davantage serait d'utiliser un seul tableau
de bytes pour toute la mémoire accessible par le CPU. Autrement dit, laisser tomber
d'avoir différents objets Memory s'occupant chacun d'une zone, et ne plus avoir
à passer par les méthodes read/write de ces objets. (Du coup, fini aussi le tableau de
8 mégaoctets).
Bon, dès que j'ai écrit ces lignes, je n'ai pas pu résister et j'ai fait le changement. Et boom, 3545000 IPS! (12 fois rapide qu'au départ)
Une autre technique qui a du potentiel serait d'implémenter les répétitions d'instructions
de chaîne (comme REP STOSB, par exemple) en Java directement. Ceci implique de regarder
l'instruction à répéter et à faire la boucle en Java (pour REP STOSB/STOSW/LODSB/LODSW)
or encore faire appel à System.arraycopy() pour le cas REP MOVSB/MOVSW (l'utilisation
d'un seul byte array pour la mémoire facilite cela).
Je garde cette option pour plus tard, si jamais l'affichage est trop lent.
Dixième partie: Interface usager
Le jeu fonctionnait bien sur ma console OUYA. Avec un clavier USB raccordé, c'était
comme s'il s'agissait d'un PC.
Soyons réalistes. La majorité des gens qui voudront l'essayer seront sur un téléphone.
Effectivement, à part peut-être quelques personnes avec un système Android box doté d'une télécommande
avec clavier alphanumérique, la plupart des gens n'auront qu'un écran tactile pour contrôler le jeu. Et
dans bien des cas, il sera assez petit, alors faire faire appel au clavier virtuel est à éviter.
Écran titre
J'ai commencé par l'écran titre, en plaçant uniquement les boutons nécessaires pour cette
étape du jeu. Sur un clavier, il faudrait appuyer sur N (commencer une partie), S (afficher l'histoire) ou
C (afficher les crédits) pour passer à un autre écran. Mais plutôt que d'avoir des boutons 'N', 'S', et 'C'
pour ces touches, j'ai écrit directement sur les boutons ce à quoi ils correspondent.
Comme première tentative, ce n'était pas mal. Il y avait assez d'espace pour que l'écran du jeu atteigne presque son
maximum de hauteur. Mais il y avait dédoublement d'information et incohérences: Pourquoi le menu texte en plus des boutons?
Pourquoi n'y a-t-il pas de bouton pour changer la langue ni pour quitter?
Pour la langue, le choix sera fait au démarrage en fonction de celle du système. Et comme pour la plupart des applications
sur Android, il faudra utiliser le gestionnaire de tâches pour la fermer.
Après y avoir pensé un moment, j'ai conclu qu'il faudrait pouvoir appuyer directement sur les choix du menu affiché
dans l'écran, sans quoi il serait préférable de ne tout simplement pas afficher de menu. C'est ce que j'ai fait.
Pour le cacher, j'ai ajouté du code permettant de remplacer par des NOP (une instruction qui ne
fait rien) le code qui affiche le menu. (Mais si un clavier est présent, le menu reste en place et ce sont
les boutons qui disparaissent).
C'était peut-être mieux, mais cela faisait beaucoup d'espace vide dans l'écran titre...
À ce moment, j'ai eu une révélation: Suffit de mettre les boutons dans cet espace!
et quelques minutes plus tard...
Vraiment beaucoup mieux!
Il fallait toutefois cacher ces boutons lors de l'affichage des écrans d'histoire et de crédits, et bien
entendu, les réafficher au retour (qui se fait en appuyant sur une touche).
J'y suis arrivé en utilisant uniquement les gestionnaires d'événements, sans devoir modifier le jeu.
Quand l'usager appuie sur Story ou Credits, simule la touche S ou C au clavier, puis cache les boutons.
Quand l'usager appuie n'importe où dans l'écran, simule la touche ESPACE au clavier, puis réaffiche les boutons. Si le jeu est déjà à l'écran titre, cela n'a pas d'effet (les boutons sont déjà visibles et ESPACE ne fait rien).
Quand l'usager appuie sur le bouton RETOUR du téléphone, simule la touche ESPACE au clavier, puis réaffiche les boutons. (Ici encore, aucun effet au menu principal).
// Called when the Story button is pressed
public void doStory(View view) {
ratillery.pressAsciiKey('s');
mButtonBox_title.setVisibility(View.INVISIBLE);
}
// Called when the Credits button is pressed
public void doCredits(View view) {
ratillery.pressAsciiKey('c');
mButtonBox_title.setVisibility(View.INVISIBLE);
}
// Called when the screen is clicked/touched
public void onScreenClicked(View v) {
ratillery.pressAsciiKey(' ');
mButtonBox_title.setVisibility(View.VISIBLE);
}
// Called when a keyboard key is pressed (includes buttons on the device)
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
// in Story or Credits, cause the game to go back to the menu
ratillery.pressAsciiKey(' ');
mButtonBox_title.setVisibility(View.VISIBLE);
return true;
}
...
}
Options pré-partie
Voici l'écran d'options original présenté avant chaque partie:
Cela faisait beaucoup d'éléments et demandait un clavier alphanumérique pour écrire
le nom des joueurs et le nombre de tours maximum. J'ai décidé pour cette étape qu'il serait
mieux de tout remplacer par des éléments d'interface Android normaux.
Un clic sur le bouton New Game de l'écran principal lance donc une nouvelle Activity offrant
presque les mêmes options.
C'est moins joli, la présence des icônes de rats ainsi que les courbes de résistances de l'air
me manquent. Mais je pourrai y revenir plus tard. L'essentiel est que ça fonctionne. Lorsque
l'usager appuie sur Start Game, le code injecte une longue série d'événements clavier
qui correspondent exactement dans l'ordre aux touches sur lesquels un utilisateur très rapide aurait
appuyé pour faire ses choix.
En jeu!
Plutôt que d'avoir un clavier numérique risquant d'être trop petit, ou occupant de l'espace
au détriment de l'écran du jeu, j'ai cru bon d'utiliser des NumberPicker, et d'avoir un
bouton pour faire feu. Cela a l'avantage qu'il est possible de modifier l'angle et la vélocité
dans n'importe quel ordre. Avec le clavier, il faut basculer entre les champs avec la touche TAB.
Faudrait-il alors avoir une touche TAB dans l'écran? Ou permettre de changer le focus en touchant
le mot «angle» et «velocity» à même la zone de jeu? (Ceci me rappelle mes réflexions sur l'écran titre...)
Lorsqu'on appuie sur Fire!, les valeurs sélectionnées par les NumberPicker sont rapidement
saisies au clavier. Comme la boîte de saisie ne sert pas, il vaudrait peut-être mieux ne pas l'afficher.
Mais cette boîte, qui s'affiche tantôt à gauche tantôt à droite indique aussi à qui le tour... Peut-être
devrais-je utiliser une flèche pour désigner le joueur et déplacer son nom et le compteur de tirs dans
l'espace vide au-dessus des NumberPicker...
Boîtes de dialogue
Lorsque le joueur appuie sur la touche ESC en jeu, RATillery lui demande de confirmer avec les touches Y et N
s'il souhaite réellement mettre fin à la partie. Je ne voulais pas avoir les boutons Oui et Non
présents en permanence dans l'écran de jeu. J'ai donc eu recours à une boîte de dialogue standard.
Je n'ai pas encore pris la peine de cacher l'ancien message, mais il n'attire par trop l'attention
grâce à l'assombrissement de l'écran.
Afin d'afficher une boîte de dialogue contenant le message approprié et au bon moment,
j'ai mis en place un système qui permet d'être averti lorsque le code du jeu passe à
certains endroits:
Lorsque le jeu demande de confirmer l'abandon de la partie
Lorsque le jeu demande si l'utilisateur souhaite rejouer
Lorsque le projectile touche la cible ou un obstacle
Pour ce dernier point (la chute du projectile), j'ai fait en sorte que le téléphone vibre.
Onzième partie: Libérer le CPU
Jusqu'à maintenant, même lorsqu'il n'y a rien à faire, le jeu occupe 100% du CPU (ou d'un des coeurs
du CPU, selon le modèle). C'est que le code du jeu se synchronise sur le
rafraîchissement vertical de la carte vidéo. Et pour y arriver, le jeu interroge le port
d'E/S 3DAh en boucle jusqu'à ce qu'un certain bit indique qu'un rafraîchissement est en cours.
Prenons l'écran titre comme exemple:
À l'écran titre, le jeu doit surveiller le clavier et changer l'image du feu (32x32 pixels)
une dizaine de fois (environ) par seconde. La logique est ainsi:
Attends que le rafraîchissement vertical (vsync) commence (se produit 60 fois par seconde ou à chaque 16.67ms)
Change l'image du feu si c'est le temps (un compteur est utilisé pour suivre le progrès de l'animation)
Regarde si une touche du clavier a été appuyée.
Retour à la première étape.
Les étapes 2 et 3 se terminent très rapidement, puisqu'il y a très peu à faire. Presque 100% du temps
s'écoule à émuler un CPU qui interroge un port d'E/S en boucle, au détriment des piles de l'appareil!
À l'intérieur de RATillery, tout cela se passe dans une fonction nommée waitVertRetrace.
L'appel à runAnimation permet à un gestionnaire d'animation d'animer certains objets (utilisé pour animer la queue des rats)
Il y a deux points d'entrée, waitVertRetrace et waitVertRetraceNoAnim. Ce dernier omet de faire tourner les animations.
La variable skip_vert_retrace permet en fait de ne pas attendre (utilisé dans certaines circonstances)
Maintenant, établission l'objectif:
Objectif: Temporairement arrêter l'émulation lorsque waitVertRetrace est appelé.
Ce qui revient concrètement à dire:
Remplacer l'attente par un appel à Thread.sleep(milliseconds)
Une bonne manière d'y arriver est d'implémenter un service d'attente du rafraîchissement vertical et de s'en
servir à l'intérieur de la fonction waitVertRetrace. Pourquoi pas l'interruption numéro 0x80 puisqu'elle est
libre. (Rappel: Lorsqu'une instruction int XX est encourue, l'émulateur fait appel à une méthode java).
Il faut insérer l'instruction INT 80h quelque part à l'intérieur de waitVertRetrace. Il y a l'espace
parfait à l'adresse 09C8 (push ax). Le code à cet endroit sera remplacé par INT 80h suivi de RET. Ainsi,
les deux points d'entrée (voir remarques ci dessus) et le mode sans attente fonctionneront encore.
Voici le code java qui s'occupe de réécrire cette partie du programme:
private void patchWaitVsync()
{
byte[] prg = mProgram.getRawBytes();
int offset = 0x09C8 + TEXT_START;
// Petite vérification, juste au cas...
if ((prg[offset] & 0xff) != 0x50) {
logE("Invalid address for patch wait vsync : Found 0x" + Integer.toHexString(prg[offset]));
} else {
// INT 80h
prg[offset] = (byte)0xCD;
prg[offset+1] = (byte)0x80;
// RET
prg[offset+2] = (byte)0xc3;
}
}
L'implémentation Java de l'interruption 80h est en deux partie. La première partie s'occupe
de suspendre l'exécution et de noter que l'attente du prochain vsync est en cours:
public void handleInt(int number) {
if (number == WAIT_VSYNC_INT_NO) {
waitingForVSYNC = true;
mCPU.suspend();
}
}
La boucle principale de l'émulateur (celle qui exécute des blocs d'instructions) renferme la
deuxième partie, qui mesure combien de temps (dans le monde réel) s'est écoulé depuis le
dernier vsync et suspends l'exécution du thread pour le temps restant avant
le prochain vsync:
time_since_last_retrace = System.nanoTime() - prev_retrace_ts;
time_to_next_retrace = (16670 * 1000) - time_since_last_retrace; // 16.67ms
realSleep(time_to_next_retrace / 1000); // a wrapper around Thread.sleep which accepts microseconds
waitingForVSYNC = false;
mCPU.resume();
Avec ces modifications, l'utilisation du CPU sur mon téléphone de test est passée de 30% à environ 7%.
Note: Ce téléphone comporte 4 coeurs. Le 30% s'explique ainsi: 25% pour le thread d'émulation du CPU qui
tournait continuellement et environ 5% pour le thread d'affichage, pour un total de 30%.
Douzième partie: Et que le son soit!
Un glorieux spécimen de PC speaker
La plupart des jeux DOS ne sont pas silencieux, et ce malgré les capacités
restreintes du PC speaker. RATillery n'échape pas à la règle. J'ai pris
le temps de composer une musique de fond et de programmer quelques effets sonores
de base. Sans cette partie audible, le jeu est tout implement incomplet.
Il était donc grand temps de mettre en place le nécessaire pour que la version pour Android de RATillery
sorte de son mutisme: L'émulation du PIT (Programmable Interval Timer), dont deux canaux jouent des rôles
absoluments essentiels:
Canal 0: Génération d'une interruption à 59.9 Hz: Régule la vitesse de la trame musicale
Canal 2: Génération d'une onde carrée destinée au haut parleur: La fréquence de cette onde change avec les notes.
Canal 0 du PIT
La sortie de ce chronomètre génère des interruptions à une fréquence configurable. Normalement
à des intervalles de 55ms (ou 18.2 fois par seconde), la
routine musicale de RATillery reconfigure la fréquence à environ 60 Hz et installe un gestionnaire
d'interruption (irq0 / int 8) qui sera donc appelé 60 fois par seconde. À chaque appel, la musique
avance d'une unité de temps.
Canal 2 du PIT
La sortie de celui-ci contrôle l'oscillation du haut parleur. Le gestionnaire d'interruption du canal 0
s'occupe de changer de note au bon moment.
Implémentation
D'abord il fallait faire en sorte que l'interruption du lecteur de musique soit appelée 60 fois par seconde.
Dans la section précédente (Onzième partie: Libérer le CPU) j'expliquait comment le jeu se synchronise
au rafraîchissement vertical de l'écran, qui, et absolument pas par hasard, est aussi d'environ 60 Hz. La boucle
principale de l'émulateur étant toute prête, il n'a fallu qu'ajouter une ligne, l'appel à soundTick():
Alors que fait soundTick()? Si le canal 0 du PIT est configuré à une fréquence se rapprochant suffisamment de 60Hz,
le gestionnaire d'interruption numéro 8 est appelé. C'est très rudimentaire, et si ceci était
un projet d'émulateur pour usage général, ce genre de raccourci serait inutilisable. Mais comme
le but est de faire tourner un jeu spécifique dont je connais les moindres détails internes,
aucun problème. C'est vite fait et ça fonctionne.
En plus d'appeler le gestionnaire d'interruptions, soundTick() informe le générateur de son (davantage
à son sujet dans un moment) sur la note à jouer pour la tranche de temps actuelle. Comme la
note ne change jamais entre les appels du gestionnaire d'interruption, cette information
n'a besoin d'être mise à jour que 60 fois par seconde, comme tout le reste.
// Hack for 60Hz int8 sound routine. Call this ~60Hz
public void soundTick()
{
if ((pit.getChannelFrequency(0)>59.0) && (pit.getChannelFrequency(0)<61.0)) {
mCPU.callInt(8);
}
mSound.setTargetFreq(pit.getChannelFrequency(2));
}
La méthode Cpu8088.callInt(int number) employée ici n'existait pas encore, il a fallut que je la crée. J'ai fait
ce que dicte la documentation d'Intel. L'adresse du gestionnaire d'interruption numéro 8 est récupérée
du IVT, les FLAGS sont poussés sur la pile puis on fait un appel far au gestionnaire d'interruption. Plus
tard, l'instruction IRET (tiens, une autre instruction manquante. Cela faisait un moment!) fait le travail inverse.
Afin d'obtenir la fréquence pour le haut parleur (Canal 2 du PIT) il suffit
de diviser la fréquence de référence (1.193182MHz) par la valeur du registre
de données du canal 2 (celui auquel le port 42h accède). C'est ce que fait la méthode getChannelFrequency() utilisée
ci-dessus.
Génération du son
J'ai utilisé la classe AudioTrack en mode streaming, où il faut écrire régulièrement
des échantillons (PCM 16-bit dans ce cas ci) en appelant la méthode write.
Chaque appel à la méthode setTargetFreq(double freq) de la classe Sound ajoute la fréquence
dans un FIFO. Un thread récupère les valeurs à la sortie du FIFO, génère l'onde à la fréquence
demandée pour une durée d'environ 16ms (~800 échantillons à 48 kHz) vers un tableau de shorts, puis
le passe à la méthode AudioTrack.write().
Il importe d'écrire assez d'échantillons pour que l'engin audio ne manque jamais d'information, sans
quoi ces trous se feront entendre sous forme de « clics » désagréables. Écrire trop d'échantillons est également
à éviter, car les appels à AudioTrack.write() finiront par bloquer et le FIFO débordera. Cela
fera sauter des notes et ruinera la musique.
Dans des conditions idéales, à 60 Hz, il faudrait écrire exactement 800 échantillons pour
chaque note, et tout irait bien. Mais en pratique, cela ne fonctionne pas bien. La boucle
de l'émulateur ne tourne pas exactement à 60Hz, et elle ne tourne pas plus vite
pour rattraper le temps perdu s'il y a un retard.
Je fais donc varier légèrement le nombre exact (toujours quelque-chose autour de 800) d'échantillons
à écrire selon le niveau du FIFO. Plus le niveau dépasse les 50%, plus le nombre d'échantillons
qui sont écrits est faible, ce qui a pour effet de vider le FIFO plus rapidement. Et inversement,
plus le FIFO est bas, plus le nombre d'échantillons écrits par cycle est haut, ce qui après
un court moment fait bloquer la fonction write. Pendant ce temps, le niveau du FIFO remonte.
Le niveau oscille donc autour de 50%, et les effets indésirables cités sont évités.
À propos de la forme d'onde
Bien que la forme correcte soit une onde carrée, je trouvais le son un peu agressant, particulièrement
dans des écouteurs. J'ai donc essayé une one sinusoïdale, mais c'était bien trop doux. Le son ne portait
pas assez et les basses étaient pratiquements inaudibles dans le haut-parleur de mon
téléphone.
J'ai ajouté des harmoniques (la 3ième et 5ième), ce qui a
fait une bonne différence et donné un timbre plus intéressant au son.
Je n'étais toujours pas convaincu pour les notes basses. Alors en bas de 200 Hz l'onde ci dessus est
mélangée avec une onde carrée modifiée. (Plus la note est basse, plus le coefficient est élevé lors
du mixage. Ce n'est pas une coupure nette, la transition est progressive).
Voici cette onde carrée modifiée:
Et lorsque mixée avec l'onde standard (fondamentale + 3/5 ièmes harmoniques):
Arrêt en douceur des notes
Un dernière chose qui était déplaisante était qu'un clic se faisait entendre lorsque le silence
revenait entre les notes. La cause? Le brusque retour à zéro lorsque le son est coupé alors que
l'onde est en amplitude (ci dessous, à gauche). Un peu de code pour redescendre à zéro en douceur (ci dessous, à droite)
et le tour est joué.
Code de génération du son
Voici le code de génération d'onde intégrant l'ensemble de ce qui vient d'être illustré.
class Sound {
private AudioTrack mTrack;
private int mSampleRate;
private short waveformBuffer[];
private double waveAngle;
private short last_sample = 0;
...
Sound()
{
mSampleRate = AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC);
bufsize = AudioTrack.getMinBufferSize(mSampleRate, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT);
mTrack = new AudioTrack(AudioManager.STREAM_MUSIC, mSampleRate, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT, bufsize, AudioTrack.MODE_STREAM);
waveformBuffer = new short[mSampleRate];
}
...
private void writeSound(int n_samples, double frequency)
{
int i;
if (frequency < 15 || frequency > (mSampleRate / 3)) {
// fade out to avoid clicks and pops
for (i = 0; i < n_samples; i++) {
waveformBuffer[i] = (short)(last_sample - (i * (last_sample / n_samples)));
}
// reset sine angle to 0, to avoid clicks/pops at next note attack
waveAngle = 0;
last_sample = 0;
} else {
double radians_per_second = 2.0 * Math.PI * frequency;
for (i = 0; i < n_samples; i++) {
double sample = Math.sin(waveAngle), coeff = 4.0;
double sample3rd = Math.sin(waveAngle * 3.0), coeff3rd = 2.0;
double sample5th = Math.sin(waveAngle * 5.0), coeff5th = 1.5;
double square;
double square_coeff = 0.5;
int low_boost_thres = 200;
if (sample < -0.5) {
square = -1;
} else if (sample > 0.5) {
square = +1;
} else {
square = 0;
}
// At low frequencies, increase square wave level (adds harmonics) for louder
// reproduction in small phone speakers.
if (frequency < low_boost_thres) {
square_coeff += (low_boost_thres - frequency) * 2.0 / (double)low_boost_thres;
}
double mixed = (coeff * sample +
coeff3rd * sample3rd +
coeff5th * sample5th +
square_coeff * square)
/
(coeff + coeff3rd + coeff5th + square_coeff);
last_sample = (short)((mixed * Short.MAX_VALUE));
waveformBuffer[i] = last_sample;
waveAngle += radians_per_second / mSampleRate;
}
}
mTrack.write(waveformBuffer, 0, n_samples);
}
}
Cette nouvelle version du jeu qui n'est plus muette désormais est disponible sur Google Play.
À suivre, mais essayez le jeu dès maintenant!
Ce projet n'est pas terminé. D'autres mises à jour sont à venir!
Toutefois, la version 4 déjà disponible, essayez-là dès aujourd'hui!
Historique des changements:
2018-08-26: Lancement de la première version (Beta)
2018-09-15: Version 2:
Support pour deux joueurs (les valeurs de tir de chaque joueur sont mémorisées entre les tours)
Cache les champs d'entrée, le curseur et la boîte pour les paramètres de tir
Affiche et cache le bouton de mise à feu
Cache les contrôles d'angle et de force pendant les tirs (mode 2 joueurs seulement). En mode un joueur,
les contrôles demeurent disponibles pour permettre au joueur de préparer son tir pendant le tour
de l'adversaire.
2018-11-10: Versions 3 and 4:
Utilisation de l'unité centrale réduite
L'émulateur est maintenant arrêté lorsque l'application est suspendue
Support de la langue française
2018-11-17: Version 5: Émulation du PC Speaker (enfin du son!)
Google Play et le logo de Google Play sont des marques de commerce de Google LLC.