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!
É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


À suivre

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

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: 14 août 2018 (Mardi)