raphnet.net banner

mobile8088: Un émulateur élémentaire pour porter RATillery à Android

twitter@raphnetlabs
Contenu

Introduction

Ce projet n'est pas terminé. Mises à jour à venir!

Toutefois, une version beta est déjà disponible. Pourquoi ne pas l'essayer?
Get it on Google Play
É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]:

[1] oui, oui, d'accord, ce sont des excuses. Je veux simplement coder un émulateur.

goto top


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.

goto top


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:

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: 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: Et donc:
	vcard_ports = new VideoCardPorts();
	mCPU.addIOPort(vcard_ports);
	mCPU.addIOPort(new PITPorts());

goto top


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!

goto top


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.
	private ImageView mScreen;
	private Bitmap mScreenBitmap;
	private final int clut[] = new int[16];
	...
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		...
		mScreen = findViewById(R.id.screen);

		mScreenBitmap = Bitmap.createBitmap(320, 200, Bitmap.Config.ARGB_8888);
		mScreenBitmap.eraseColor(Color.GREEN);
		mScreen.setImageBitmap(mScreenBitmap);
		...
		clut[0] = Color.rgb(0,0,0);
        clut[1] = Color.rgb(0x00, 0x00, 0xAA);
        clut[2] = Color.rgb(0x00, 0xAA, 0x00);
        clut[3] = Color.rgb(0x00, 0xAA, 0xAA);
        clut[4] = Color.rgb(0xAA, 0, 0);
        clut[5] = Color.rgb(0xAA,0x00,0xAA);
        clut[6] = Color.rgb(0xAA, 0x55, 0);
        clut[7] = Color.rgb(0xAA,0xAA,0xAA);
        clut[8] = Color.rgb(0x55, 0x55, 0x55);
        clut[9] = Color.rgb(0x55, 0x55, 0xff);
        clut[10] = Color.rgb(0x55, 0xff, 0xff);
        clut[11] = Color.rgb(0x55, 0xff, 0xff);
        clut[12] = Color.rgb(0xff,0x55,0x55);
        clut[13] = Color.rgb(0xff,0x55,0xff);
        clut[14] = Color.rgb(0xff, 0xff, 0x55);
        clut[15] = Color.rgb(0xff,0xff,0xff);

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

goto top


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 pixelDeuxième pixel
7654 3210
IRGB IRGB

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:
LignesAdresse
0, 4, 8 ... 1960x0000 - 0x1F3F
1, 5, 9 ... 1970x2000 - 0x3F3F
2, 6, 10 ... 1980x4000 - 0x5F3F
3, 7, 11 ... 1990x6000 - 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

Première image

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

goto top


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

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)

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.

goto top


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;
	}

goto top


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

goto top


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

goto top


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.

// 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:

Pour ce dernier point (la chute du projectile), j'ai fait en sorte que le téléphone vibre.

goto top


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:
  1. Attends que le rafraîchissement vertical (vsync) commence (se produit 60 fois par seconde ou à chaque 16.67ms)
  2. Change l'image du feu si c'est le temps (un compteur est utilisé pour suivre le progrès de l'animation)
  3. Regarde si une touche du clavier a été appuyée.
  4. 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.

Voici le listing de cette fonction:
  2458                              <3> waitVertRetrace:
  2459 000009BE E86FFA              <3>     call runAnimations
  2460                              <3> waitVertRetraceNoAnim:
  2461 000009C1 F606[9E00]FF        <3>     test byte [skip_vert_retrace], 0FFh
  2462 000009C6 7511                <3>     jnz _skip_vert_retrace
  2463                              <3>
  2464 000009C8 50                  <3>     push ax
  2465 000009C9 52                  <3>     push dx
  2466 000009CA BADA03              <3>     mov dx, 3DAh
  2467                              <3> _waitNotInRetrace:
  2468 000009CD EC                  <3>     in al, dx
  2469 000009CE A808                <3>     test al,08h
  2470 000009D0 74FB                <3>     jz _waitNotInRetrace
  2471                              <3> _notInRetrace:
  2472 000009D2 EC                  <3>     in al, dx
  2473 000009D3 A808                <3>     test al,08h
  2474 000009D5 75FB                <3>     jnz _notInRetrace
  2475 000009D7 5A                  <3>     pop dx
  2476 000009D8 58                  <3>     pop ax
  2477                              <3> _skip_vert_retrace:
  2478 000009D9 C3                  <3>     ret
Remarques: 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%.

goto top


Douzième partie: Et que le son soit!

Un glorieux spécimen de PC speaker

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:

  1. Canal 0: Génération d'une interruption à 59.9 Hz: Régule la vitesse de la trame musicale
  2. 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():
{
	...
	realSleep(time_to_next_retrace / 1000);
	waitingForVSYNC = false;
	mCPU.resume();
	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.

goto top


À 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!
Get it on Google Play
Historique des changements:
Google Play et le logo de Google Play sont des marques de commerce de Google LLC.

goto top


Les marques de commerce utilisées dans ce site appartiennent à leurs propriétaires respectifs.
Copyright © 2002-2018, Raphaël Assenat
Site codé avecSite codé avec vimDernière mise à jour: 19 novembre 2018 (Lundi)