PDA

Voir la version complète : [Projet] PatrickBoy


Brunni
12/06/2009, 20h12
PatrickBoy
Console de jeu virtuelle
ALPHA

Version 0.00 (http://brunni.dev-fr.org/tmp/cat/oth/pc/patboy/PatrickBoy.7z)


Présentation :
Voilà une idée de projet complètement inutile qui m'a traversé l'esprit: faire une console de jeu virtuelle. Ca m'est venu au cours de compilation, au lieu de générer du code pour MIPS, autant se faire une machine virtuelle et y ajouter un petit hardware dédié.

Galerie d'image :
http://brunni.dev-fr.org/tmp/cat/oth/pc/patboy/scr001.png

Historique :
12.06.2009: sortie initiale

Comment utiliser :
-

Autres informations :
Voici donc la PatrickBoy, croisement entre la Game Boy et les jeux inutiles Super Patrick (je lui avais même fait un site dans un autre cours: http://brunni.dev-fr.org/tmp/cat).
Derrière cette présentation burlesque se cache en fait quelque chose d'assez sérieux: un projet purement software visant la définition d'un CPU "minimal" permettant d'avoir de bonnes performances malgré une fréquence relativement faible (4 MHz étaient prévus initialement, mais jusqu'à 16 sont possibles comme c'est la fréquence de la carte mère). CPU auquel on rattache un hardware permettant de faire du graphisme, du son et autres. Il faudra alors programmer un assembleur, un émulateur et éventuellement un compilateur pour un langage de haut niveau. Evidemment toutes les specs devaient être de l'ordre du faisable, donc une réflexion sur comment on pourrait l'implémenter à été faite à chaque fois, histoire de rester réaliste. Bref c'est inutile mais c'est pédagogique, au moins ça. Peut être que mon compte rendu pourra intéresser des gens, c'est pour ça que j'ai décidé de le poster ici.

Commençons donc avec le CPU: il s'agit d'un RISC 16 bits. C'est à dire que les instructions, les registres et tout sont sur 16 bits.
Il s'agit d'une architecture de Harvard, c'est à dire que le code et les données sont séparées. Pourquoi? si l'adressage est sur 16 bits on est limité au maximum à 64 ko de mémoire: RAM, ROM, VRAM tout compris. C'est bien peu... Une solution est alors de faire de la pagination (par exemple laisser 16k pour la RAM, et préciser quels 16k on adresse). La deuxième est de compacter tout ça: comme les instructions sont sur 16 bits (2 octets) on sait que toute instruction est exécutée à une adresse paire, on peut donc éliminer le dernier bit (toujours nul). Si en plus on oblige l'exécution du code en ROM seulement, on se retrouve alors avec 128 ko exécutables.

Le CPU dispose de 30 instructions au total, en comptant les différents modes: par exemple add r0, r1 (registre-registre) n'est pas la même chose que add r0, 7 (registre-littérale). L'assembleur était quelque chose d'assez intéressant à faire, mais loin d'être aussi facile qu'on le pense, comme on le verra plus tard. Donc la syntaxe ça c'est chacun qui choisit. Par exemple une routine qui remplit la palette de nuances de rouge:

#include "patlib.h"
$routine
mov r0, PALETTE ; destination
mov r1, 0 ; couleur
:boucle
st r1, [r0+] ; stockage dans la palette et incrémentation du pointeur
add r1, 1 ; prochaine couleur (+1)
bne r1, 32, :boucle ; on va jusqu'à 32 (bne = branch if not equal)
return
A noter que les labels sont toujours précédés d'un signe (: pour un label local, $ pour un label global). Cela permet d'alléger pas mal le langage, d'éviter les ambiguïtés (si je lis "boucle", s'agit-il d'un registre ou d'un label?). On notera qu'il n'y a toutefois pas de distinction au niveau des nombres, comme beaucoup d'assembleurs le font (#nombre). Il peut dès lors avoir des ambiguïtés: si je fais mov r0, PALETTE, s'agit-il d'un registre PALETTE ou d'une constante PALETTE? On traitera ces problèmes plus tard.

Dans le code ci-dessus si vous regardez le document vous remarquez qu'il n'existe pas d'instruction pour effectuer directement bne registre, constante 6 bits, label. Le plus proche est b[condition] registre, registre, offset. L'assembleur va alors devoir modifier l'instruction et générer le code suivant (at étant un registre réservé à l'assembleur):
mov at, 32
bne r1, at, -4
Pourquoi -4? Parce qu'avec l'instruction supplémentaire il faudra revenir 4 instructions en arrière (en incluant le bne lui même) pour se replacer sur l'instruction désignée par le label :boucle (st). Prenons maintenant un autre exemple:
b :label ; saut inconditionel 128 instructions plus loin
space 256 ; 256 instructions
:label
On remarque alors qu'on n'a pas d'instruction permettant de faire un saut avec un offset >= 128 ou < - 128. Par contre on a une autre instruction b registre, qui utilise la valeur d'un registre pour le saut. Alors on peut le remplacer par ceci:
mov at, 256
b at
Mais mov at, 256 n'est pas non plus possible (il n'y a que 16 bits de place dans une instruction, et comme il faut en réserver pour l'opcode, c'est à dire "quelle est l'instruction à exécuter?" ainsi que le registre de destination, on ne peut pas avoir une constante 16 bits complète...). Par contre on peut le décomposer en deux instructions, une permettant de mettre une constante 8 bits dans un registre, et une autre permettant de mettre une constante 8 bits dans le haut d'un registre (movhi). Ainsi on se retrouve avec:
mov at, 0
movhi at, 1 ; 0x0100
b at
Sur ce modèle on pourrait alors simplifier le jeu d'instruction en ne définissant que des opérations sur des registres. En effet:
add r0, 1
pourrait devenir:
mov at, 1
add r0, at
En fait ce n'est vraiment pas performant, ce genre d'opérations étant réalisé souvent. J'ai alors décidé de proposer une variante des instructions add (+), sub (-), shl (<<) et shr (>>) avec une constante 4 bits non signée (0 à 15). Pour les autres c'est moins grave...
Maintenant le problème qui se pose, c'est quand générer de "gros sauts" et quand générer de petits quand on ne sait pas encore où va se placer un label? (i.e. on ne l'a pas encore rencontré)
petit saut:
b +35
grand saut:
mov at, 35
movhi at, 0
b at
Perso j'ai utilisé une heuristique: je considère au début que les labels non définis sont à l'adresse 0 et génère le code correspondant. Ensuite une fois que tout le code a été généré et que donc tous les labels sont associé à une position dans le code, je refais une passe pour voir si on peut compacter le code (comme des labels qui n'étaient pas définis avant le sont maintenant, il est possible qu'on puisse effectuer de petits sauts là où on avait dû en faire de gros avant). Et ainsi de suite jusqu'à ce qu'il n'y ait plus de changement.

Autre truc intéressant: la résolution d'expression mathématique. On peut par exemple écrire:
mov r1, (PALETTE + 10) ; PALETTE étant une constante
mov r0, 31
st r0, [r1]
Il faut les entourer de parenthèses pour éviter de lancer le converto RPN pour de bêtes constantes (il est lent, il utilise une pile). Donc ça c'est comme je l'ai dit une conversion RPN. Il est possible d'utiliser des constantes telle que PALETTE car elles sont en fait résolues par le préprocesseur... le même qui va nous permettre d'utiliser les macros comme on va le voir plus loin.

Donc nous y voilà, l'assembleur supporte aussi les macros et autres joyeusetés qui peuvent être définies à la C.
#define add(i, j) ((i) + (j))
mov r0, add(1, 2) ; r0 = 3
En fait le préprocesseur se charge d'analyser chaque "mot" qui compose le fichier entré. Par mot en fait on entend chaque élément syntaxique qu'on peut regrouper. Par exemple si on voit un chiffre, alors tous les chiffres qu'on voit ensuite, le premier compris, composent un nombre. On s'arrête dès qu'on trouve quelque chose qui n'est pas un chiffre: c'est le début du prochain mot. De même, s'il commence par une lettre alors c'est un identificateur, qui se prolonge tant qu'on trouve des lettres ou des chiffres.
Le préprocesseur analyse donc l'entrée et la convertit en "mots" (en fait même une virgule est un mot, au sens que c'est un élément syntaxique). On a en sortie ce qu'on appelle des jetons: c'est à dire un texte lu associé à un élément syntaxique. A chacun de décider ce qui forme les éléments syntaxiques de son langage. Si je prends par exemple une instruction, pour moi cela représente:
mov r0, 10
alors on a:
<jeton type="identificateur" texte="mov">
<jeton type="espace" texte=" ">
<jeton type="identificateur" texte="r0">
<jeton type="virgule" texte=",">
<jeton type="espace" texte=" ">
<jeton type="nombre" texte="10">
Bon en réalité c'est un mensonge: je ne crée pas de jeton pour les espaces. Pareil pour les commentaires. Ils sont facultatifs. Dans certains cas ils serviront par exemple à terminer un identificateur mais sinon ils n'apportent rien. Il faut savoir que pour le moment le code de conversion en jeton est ce qui rend mon assembleur très lent, principalement à cause de l'utilisation des vector et des string C++.
On peut ensuite créer un analyseur simple qui prend ces jetons en entrée et détermine quoi faire avec. Par exemple on sait qu'en début de ligne on doit avoir un nom d'instruction. On va ensuite effectuer la procédure correspondant à l'instruction qu'on a rencontrée. J'ai fait une fonction prochain qui me permet de vérifier que le prochain jeton est bien d'un type (pour la décision) et une fonction lit retournant le prochain jeton et avançant le curseur (de sorte qu'à la prochaine lecture on aura le prochain).
Prenons mov, elle est relativement complète:
void MOV() {
/* Bon alors la syntaxe est:
* mov registre, registre
* mov registre, nombre
* mov registre, label
*/
// Dans tous les cas on lit un premier registre
int regDest = litRegistre();

// Si le prochain est un identificateur alors on a un registre qui vient
if (prochain(IDENTIFICATEUR))
emetMovRR(regDest, litRegistre());
// Sinon ptet un nombre
else if (prochain(NOMBRE))
emetMovRC(regDest, litNombre());
// Ou un label (:lab, $lab)
else if (prochain(DEUXPOINTS) || prochain(DOLLAR))
emetMovRC(regDest, litLabel() /* plus compliqué celui là... */);
else
erreur("foutage de gueule spotted");
}

int litNombre() {
// Conversion en entier de la chaîne lue (le nombre en question)
return atoi(lit()->texte);
}

int litRegistre() {
string texte = lit()->texte;
if (texte == "r0")
return 0;
else if (texte == "r1")
return 1;
...
}

Bref je sais pas si je suis clair, mais ça paraît tout à fait plus facile de concevoir un petit langage dans ces conditions! Du moins vous pouvez comparer avec le lanagage de script de GBA Graphics si vous voulez vous faire une idée de comment ça peut être sans.
Donc voilà, ce préprocesseur, on peut alors lui ajouter deux trois trucs sympa, comme par exemple #include, qui va ouvrir un autre fichier et poursuivre la lecture sur celui-ci. Il ajoutera les jetons qu'il lit sur la même liste en sortie.
On peut aussi permettre la définition de macros à la C. Reprenons l'exemple:
#define add(i, j) ((i) + (j))
mov r0, add(1, 2) ; r0 = 3
Ce qui s'est passé c'est que le préprocesseur lit simplement les éléments syntaxiques comme ils arrivent. Toutefois quelque chose peut attirer son attention: il s'agit du caractère dièse (#). Cela indique que ce qui suit le concerne lui. Il va alors lire define et savoir qu'il s'agit d'une définition de macro. La lecture du jeton '(' (PAR_GAUCHE) et des différents arguments peut se faire assez simplement, comme par exemple - avec une fonction lit(type) qui s'assure que le prochain jeton est d'un certain type et lance une erreur sinon, et consomme_si_present qui lit le prochain jeton et le jette s'il est bien d'un certain type et retourne vrai, ou ne fait rien et retourne faux sinon:
lit(PAR_GAUCHE);
// Peut être qu'il y a des arguments
if (!prochain(PAR_DROITE)) {
liste<string> arguments;
// S'il ne ferme pas, alors il faut forcément un identificateur (nom de param)
arguments.ajoute(lit(IDENTIFICATEUR)->texte);
// Arguments additionnels, séparés par une virgule
while (consomme_si_present(VIRGULE))
// Prochain argument
arguments.ajoute(lit(IDENTIFICATEUR)->texte);
}
lit(PAR_DROITE);
Ensuite on prend ce qu'il y a après et on l'ajoute dans une liste de jetons qui représente le texte de la macro, jusqu'à la fin de la ligne ou une virgule au niveau 0 de parenthèse si c'est un #define, ou un #endmac si c'est une #macro.
En effet, notons qu'on peut définir plusieurs macros à la suite comme ceci, impossible en C.
#define x=r0, y=r1
mov x, 0
#undef x, y
J'accepte aussi un égal séparant le nom de macro de son texte, c'est plus clair ici.
Une fois une macro définie, le préprocesseur continue d'analyser le texte et de le convertir en jetons, mais une fois qu'il trouve un identificateur (un mot texte) il vérifie s'il ne fait pas partie des macros. Si c'est le cas il va vérifier s'il est suivi d'une parenthèse et commencer à rassembler les arguments, qu'il passera à la macro. Ensuite le préprocesseur va voir son entrée transférée sur le texte de la macro en question. Là aussi lorsqu'il rencontre un identificateur, il va vérifier récursivement s'il s'agit d'une macro, ou cette fois s'il s'agit d'un nom de paramètre. Il le remplacera alors par le texte lu lors de l'appel.

On a vu plus tôt que certaines instructions pouvaient paraître ambiguës. En effet si j'écris:
mov r0, PALETTE
Qu'est-ce donc que ce PALETTE? S'il s'agit d'une constante (valant 0xF000, à tout hasard) alors c'est un nombre, sinon c'est peut être un registre, ou rien du tout (erreur). Comme l'assembleur reçoit le code déjà "préprocessé" (converti en jetons) le problème ne se posera pas: il verra mov r0 [virgule] 61440. Par contre pour le programmeur il se peut que cela pose un problème. Si on fait par exemple:
#define r0=10
mov r1, r0
On a une confusion! L'utilisateur pensait peut être déplacer r0 dans r1, mais en fait il y met 10. Evidemment ce cas a peu de chances d'arriver, mais on n'est pas à l'abri d'une faute d'orthographe... c'est pourquoi j'ai décidé de précéder les labels de symboles, car la plupart des instructions acceptent des labels autant que des nombres. Ainsi quelqu'un qui a défini un label pal et une constante Pal et fait ceci par inattention:
mov r0, pal
Il va chercher bien longtemps son erreur car son registre va contenir l'adresse du label pal au lieu de la valeur qu'il a définie! Autant dire mission impossible de déboguer un tel problème, surtout si cette valeur est utilisée pour stocker quelque chose: on va aléatoirement détruire la RAM en écrivant ailleurs et provoquer des choses inattendues.

Ensuite reste à proposer de petites aides au programmeur. Par exemple vous avez vu plus haut l'instruction return. En fait il s'agit d'une pseudo instruction. Un appel réalise en fait ceci, de façon très similaire à l'ARM: il sauve la valeur courante du pc (registre spécial r15, pointeur d'instruction en train d'être exécutée, qui lors de l'exécution d'une instruction i, pointe sur i+1, c'est à dire la prochaine) dans un registre spécial appelé lr (r14) et met dans pc l'adresse de l'instruction appelée de sorte que la prochaine sur la liste sera celle-ci. Au retour il suffit alors de mettre dans pc, prochaine instruction à exécuter, la valeur de lr sauvée pour retourner juste à l'endroit où l'appel s'est fait. Si une fonction en appelle une autre, alors elle devra prendre soin de sauvegarder la valeur de lr car cet appel le modifiera. Exemple:
$main
call :fct1
b -1 ; boucle infinie, en effet si cette instruction est à l'adresse 0001, lors de l'exécution pc pointe sur 0002, donc le brancher à 2 - 1 = 1 lui refera exécuter éternellemetn cette même instruction

:fct1
push lr
call :fct2
pop lr
return

:fct2
return

Là aussi les instructions push et pop utilisent un registre dédié appelé de son petit nom sp (stack pointer) qui n'est en fait autre que r13 et pointe sur le sommet de la pile. Ainsi:
push r0
pop r0
se transforment respectivement en:
st r0, [-sp]
ld r0, [sp+]
Explication: la pile pointe au début sur la fin de la RAM (0x8000). Un premier push va décrémenter cette adresse (0x7ffe) et y placer le mot à écrire. D'où la pré-décrémentation, notée [-sp], c'est à dire qu'on décrémente d'abord sp puis on y écrit le mot. Pour récupérer, c'est l'inverse: comme sp pointe déjà sur le sommet de la pile, c'est à dire le dernier élément écrit, on n'a pas besoin de pré-incrémenter, par contre on va devoir le faire après pour préparer la pile pour le prochain élément (ici 0x8000 = pile vide). D'où la post-incrémentation, notée [sp+].

Bon alors maintenant le CPU il est plus ou moins bon ^^ reste à donner une signification à ce qu'on va faire avec. Pour cela j'ai dû définir la memory map, c'est-à-dire quelle adresse correspond à quoi. A chacun de décider ce qu'on met où, selon le bon sens. Rappelez-vous qu'on a 64 ko disponibles car l'espace d'adressage est sur 16 bits (permet d'adresser simplement via un registre). Pour ma part j'ai décidé d'allouer 16 ko pour la ROM (en fait il s'agit d'une fenêtre, on pourra dire quels 16k - autrement dit quelle page de 16k on utilise afin d'accéder à toute la ROM), 16k à la RAM même s'il y en a 32, là pareil un bit dans le registre de configuration PAGECTL qu'on verra plus tard permet de choisir si c'est les 16 premiers k ou le reste. Pareil pour la VRAM, 16k là aussi. Ensuite j'ai donné 8 ko au chip son qui n'est pas implémenté pour le moment. Il reste 8k, dont 4k qui ne me sont pas utiles pour le moment et 4 autres ko, les derniers (f000 - ffff) qui seront plutôt chargés, car on va y mettre la palette (512 octets pour 256 couleurs), l'OAM (attributs des objets, aussi appelés sprites, 640 octets pour 128 sprites) et les registres de configuration, zone aussi appelée IO (derniers 2k, f800-ffff).

Concernant la RAM, on laissera la gestion 100% libre à l'utilisateur. Un truc tout de même: le BIOS de ma console copie une partie de la cartouche dans les 16 premiers ko de la RAM. Cela sert pour l'initialisation, de sorte qu'on puisse écrire ceci pour se réserver des variables pré-initialisées:
#section ram
:var1
space 2 ; 2 octets
:var2
data 12 ; 2 octets, vaut 12 de base
:var3
data8 12 ; 1 octet, vaut 12 de base
:var4
string "Hello world" ; liste d'octets, automatiquement complétés d'un octet nul

#section code
$main
mov r1, :var2
ld r0, [r1]
add r0, 1 ; 13
st r0, [r1]

mov r0, 0
st8 r0, :var3 ; décomposé en mov at, :var3 et st8 r0, [at]
A faire attention aussi, la pile se situe à la fin des 16 premiers ko de la RAM, il faudra alors éviter de se marcher dessus (les variables, elles, commencent au début).

Pour l'interprétation de la VRAM, c'est complètement libre. J'ai décidé d'un GPU avec un certain modèle, et j'ai donné donc une certaine signification à ce qui s'y trouvait: où on met la map, les tiles, quel est leur format, etc.
Perso j'ai fait un GPU pouvant afficher 2 plans et jusqu'à 128 sprites avec des effets d'addition et de soustraction. Les 2 plans sont des maps comme d'habitude et on retrouve des registres de configuration situés dans la zone IO (adresses f800 et plus), permettant par exemple de définir la valeur de scrolling, configurer le mode graphique (bitmap 4, 8 bits ou texte), quelle map utiliser, etc. pour plus d'informations, voir le document associé.

Je vais abréger le reste car c'est assez simple si on connaît un peu le dév sur GBA par exemple. Passons maintenant à l'émulateur.
En fait cette étape a été étonnamment simple. J'ai tout d'abord créé une application Direct3D (parce que je voulais absolument la VSync et j'ai jamais compris comment l'avoir à coup sûr en OpenGL. Mais il faudrait changer ça parce que le SDK est vraiment trop lourd et Windows only).
Ensuite comme le hardware de notre console est constitué de plusieurs éléments qui travaillent indépendamment les uns les autres, il est difficile de le représenter par un logiciel. Quelque chose qui vient en tête toutefois est de décomposer l'exécution en petites unités de temps et exécuter "une partie" de ce que chacun doit faire à chaque unité de temps.
Cette unité de temps dans mon cas est le cycle d'horloge de l'oscillateur principal, celui qui est sur la carte mère. Il est cadencé à 16 MHz, et il déclenche tous les événements pouvant arriver aux éléments matériels. Il constitue donc une source idéale.
J'ai donc défini les timings: ce que fait chaque élément, et à quel fréquence. Ensuite il faut simplement à chaque cycle d'horloge effectuer les actions du matériel qui ont lieu, selon les spécifications théoriques:
* exécuter une instruction du CPU et attendre le temps qu'il l'exécute (faire autre chose pendant ce temps)
* générer un pixel de l'affichage tous les 4 cycles
* gérer les fin de ligne, fin de frame et générer les interruptions appropriées, mettre à jour les registres (DISPSTAT, n° de ligne en cours) et les waitstates en conséquence (la VRAM est plus lente d'accès si l'affichage est en cours).
* générer une sample audio tous les 381 cycles
* continuer le DMA s'il est en cours et qu'il a terminé
* décrémenter le timer et générer une interruption s'il y a lieu
* etc.
Ce modèle est louuuuuurd! En fait on va consommer énormément de CPU si on fait vraiment ça à tous les coups.
On peut toutefois optimiser en ne décrémentant par exemple pas le registre à tous les ticks d'horloge, mais par exemple calculer lorsque le programme utilisateur (le jeu en cours d'émulation quoi) le demande, le temps qui s'est écoulé depuis le dernier événement le concernant et connaître ainsi à quelle valeur il devrait être. Pareil pour l'affichage, en principe pour être précis on devrait générer pixel par pixel, mais de toute façon ce qui se passe au milieu des lignes est assez peu déterministe et un jeu ne devrait pas compter là-dessus. On dessinera alors l'affichage ligne entière par ligne. Cela déchargera énormément le CPU, et on pourra alors mettre en pause le GPU durant une période plus grande.
En effet lorsqu'un élément, par exemple le CPU, exécute quelque chose, il va ensuite se mettre en "pause", c'est-à-dire qu'il va attendre d'être réveillé lorsqu'il aura terminé. Entre temps il ne se passera rien le concernant, mais les autres composants eux vont pouvoir continuer à bouger. Pour le CPU s'il exécute une instruction qui lui a pris 2 cycles et qu'il était à 4 MHz (diviseur de fréquence par 4 comparé aux 16 MHz) alors il se mettra en pause pour 8 cycles. A chaque ligne le contrôleur vidéo se mettra en pause le temps (théorique) de dessiner sa ligne, soit 1232 cycles si ma mémoire est bonne.

Toutes les pièces s'emboîtent! Bon allez c'est l'heure de bouffer je rédigerai la suite une autre fois si ça intéresse des gens!
Je vais aussi terminer cet ému si j'ai le temps et rendre les choses plus propres. En attendant voici un lien de téléchargement. Vous pouvez compiler une petite ROM et l'exécuter avec le build.bat fourni. Le CPU et la RAM ont vraiment des clocks de merde, genre 8 kHz. C'est ultra lent, on voit les choses s'écrire à l'écran. Vous pouvez changer ça dans CPU.cpp (unsigned cpu_cycle_factor = 2048, devrait être 4 pour 4 MHz).
Pour l'instant le contrôleur (clavier), les timers et les fenêtres (dont les specs sont sujettes à changement) ne sont pas implémentées.

Screenshot:
Document: http://brunni.dev-fr.org/tmp/cat/oth/pc/patboy/Documentation.pdf
PatrickBoy tout complet: http://brunni.dev-fr.org/tmp/cat/oth/pc/patboy/PatrickBoy.7z

Ass-Itch
13/06/2009, 13h28
Ce qui est cool avec Brunni c'est qu'il fait jamais des trucs pointus, que des merdouilles en 3 lignes de code. Quelqu'un peut m'expliquer le concept de "console de jeu virtuelle" ? :-'

Bobby Sixkilla
13/06/2009, 13h36
C'est comme un émulateur, sauf que la machine n'existait pas avant. Enfin, je crois... J'ai pas tout capté. :D

Brunni
13/06/2009, 14h22
Lol j'ai pas dû être clair ^^
C'est une console de jeu inventée. Le but c'est de voir à toutes étapes théoriques (sans vraiment faire le matériel parce que je m'y connais pas) ce qu'il y a à faire: du design de la console, ce que fait le hard, le CPU, puis les outils genre un assembleur pour pouvoir écrire du code pour la machine, un émulateur pour pouvoir tester (la console n'existe pas en vrai donc faut bien un moyen), mais aussi d'autres outils tels que GBA Graphics qui existait déjà avant, un tracker pour composer de la musique, un compilateur pour un langage plus évolué tel que le C, etc.
Bref on voit que la liste est longue, c'est d'ailleurs pour ça que je n'ai pas été jusqu'au bout.
En fait c'est une intro pour pas mal de choses dans le bas niveau, et ça peut intéresser les gens qui n'ont pas le temps de s'y mettre, au moins voir un peu comment c'est fait. Ca n'a pas d'intérêt réel en tant que tel, par contre je pense faire un vrai ému pour une vraie console depuis 0 ensuite, genre la Game Boy. Les bases sont les mêmes excepté que la console existe déjà donc y a pas la réflexion préliminaire à faire, par contre pas mal de docs à se farcir ^^

Ayla
13/06/2009, 16h55
J'avoue que l'idée d'un système entièrement fictif et virtuel ne m'a jamais traversé l'esprit !
Vraiment très intéressant :)

Question : pourquoi que 30 opcodes ? Comme c'est toi qui définit ton propre CPU, pourquoi ne pas y ajouter quelques opcodes bien pratiques ? Y'en a un par exemple utilisé dans les DSP, qui effectue une addition ainsi qu'une multiplication (je ne me rappelle plus de son nom...).

Brunni
13/06/2009, 17h19
Le but c'était d'être minimal, dans l'esprit RISC. Si j'avais vraiment voulu coder 40000 opcodes je me serais attelé à émuler un 68k par exemple ;)
En fait si tu regardes le jeu d'instruction y a pas besoin d'aller si loin, déjà rien que faire un masque c'est compliqué:
; branche à bitset si r0 & 0x100 (bit 8) sans modifier r0
mov r2, r0 ; copie de r0
mov r1, 1
shl r1, 8 ; r1 = 1 << 8
and r2, r1
bnz r2, :bitset
Besoin de 2 registres temporaires et 5 instructions...
Dans d'autres CPUs on a inclus des instructions bittest par exemple pour éviter justement ces problèmes. Mais c'est là qu'on voit la magie de ce que sait faire l'ARM, où ça donnerait:
ands r2, r0, #0x100 ; codage constante 8 bits + décalage 5 bits (imm8m)
bne bitset
Bon en même temps l'ARM est un jeu d'instructions 32 bits donc t'as plus de libertés, mais il est tout simplement bien pensé, mais complexe à implémenter. Le thumb (16 bits) lui est super impressionnant mais toujours aussi compact, et ça pour émuler (décoder les opérandes) c'est très lent. Un Z80 (CISC) par exemple est un bonheur en comparaison, tu lis un octet ça te dit quelle instruction c'est (donc une simple table de saut de 256 entrées pour décoder). Ensuite les opérandes éventuelles sont des octets supplémentaires.

Ass-Itch
14/06/2009, 16h00
Ok, merci pour l'explication Brunni ^^ Faut dire en tant que non-codeur absolu j'avais du mal à voir les tenants et les aboutissants d'un tel concept.

Nesgba
14/06/2009, 21h00
cool !

C'est sûr que ça change radicalement de l'opcode arm32, mais ça a son charme je trouve, en tout cas c'est du bon boulot que tu as fait la. :bravo:
Vivement que ça soit en phase beta :wub:

je me serai bien laissé tenter par un joli voxel à l'ancienne mais la vitesse de l'emu m'a ramené à des considérations plus pragmatiques.
En attendant voici un sprite à la con qui rebondit sur les bords, asm rulez :bave:

C'est vraiment très lent.

code:
;;################################################ ##################################################
;;## principal
;;################################################ ##################################################
$main

BLDCTL_OR(BLDCTL_NONE) ; configure le blending
BG0CTL_OR(BGXCTL_MODE(0) | BGXCTL_MAPBASEBLOCK(0) | BGXCTL_CHARBASEBLOCK(0)) ; configure BG0
DISPCTL_OR(DISPCTL_BG0_ENABLE) ; active BG0

DMAtoPAL(0,$old_pal,255)
call $pal_RGBtoBGR

mov r0,BGR555(31,24,0)
st r0,PALETTE

DMAtoRAM(0,$old_bmp,(32*32))

mov r0,VRAM ; dst
mov r1,RAM ; src
mov r2,10 ; xpos
mov r3,10 ; ypos
mov r4,32 ; xsize
mov r5,28 ; ysize
mov r6,1 ; xdir
mov r7,-1 ; ydir


:loop_principal
mov r0,VRAM ; dst
mov r1,0

call $frameCollision
call $drawCubeOutline

mov r1,RAM ; src
mov r8,r5 ; y size
add r2,r6 ; xdir
add r3,r7 ; ydir

:loop_y
call $drawhlineDiffuse
add r0,240
add r1,32
sub r8,1
bnz r8,:loop_y

;call $screen_clear ; à éviter sinon on tombe à 0.02 fps
;call $pal_grad ; ne marche pas

bra :loop_principal




;;################################################ ##################################################
;;## conversion rgb -> bgr
;;################################################ ##################################################
$pal_RGBtoBGR
push r0,r1,r2,r3,r4

mov r0,PALETTE
mov r2,255

:lbl_pal_RGBtoBGR
ld r1,[r0]

; rouge
mov r3,r1
shr r3,10
and r3,(0x1f)
; bleu
mov r4,r1
shl r4,10
and r4,(0x1f<<10)
; vert
and r1,(0x1f<<5)
; recombinaison
or r1,r3
or r1,r4

st r1,[r0+]

sub r2, 1
bnz r2,:lbl_pal_RGBtoBGR

pop r0,r1,r2,r3,r4
mov pc,lr


;;################################################ ##################################################
;;## r2=x r3=y r4=sizex r5=sizey r6=dirx r7=diry
;;################################################ ##################################################
$frameCollision

bgt r2,12,:lbl_frameCollision_xsub ; droite
beq r2,0,:lbl_frameCollision_xadd ; gauche
bra :lbl_frameCollision_tsty

:lbl_frameCollision_xsub
mov r6,-1
bra :lbl_frameCollision_tsty
:lbl_frameCollision_xadd
mov r6,1


:lbl_frameCollision_tsty


bgt r3,12,:lbl_frameCollision_ysub ; bas
beq r3,0,:lbl_frameCollision_yadd ; haut
bra :lbl_frameCollision_end

:lbl_frameCollision_ysub
mov r7,-1
bra :lbl_frameCollision_end
:lbl_frameCollision_yadd
mov r7,1


:lbl_frameCollision_end
mov pc,lr


;;################################################ ##################################################
;;## r0=dst r1=src r2=x r3=y r4=sizex r5=sizey r6=dirx r7=diry
;;################################################ ##################################################
$drawCubeOutline
push lr,r8,r9

; horizontal
mov r8,r2
mov r9,r3
push r2,r3
mov r2,r8
mov r3,r9
beq r7,1,:lbl_drawCubeOutline0
add r3,r5
:lbl_drawCubeOutline0
call $drawhlineFlat

; vertical
pop r2,r3
mov r8,r2
mov r9,r3
push r2,r3
mov r2,r8
mov r3,r9
beq r6,1,:lbl_drawCubeOutline1
add r2,r4
:lbl_drawCubeOutline1
call $drawvlineFlat

pop lr,r8,r9,r2,r3
mov pc,lr


;;################################################ ##################################################
;;## r0=dst r1=src r2=x r3=y r4=xsize
;;################################################ ##################################################
$drawhlineDiffuse
push r5,r6,r7,r8,r9
mov r7,r1 ; psrc
mov r8,r4 ; size

; ajoute x
mov r6,r2

; ajoute y
mov r9,r3
shl r9,8
add r6,r9
mov r9,r3
shl r9,4
sub r6,r9

; méthode soft
add r6,r0 ; pdst1 = x + y*240 + pdst0
:loop_drawhlineDiffuse
ld8 r9,[r7]
st8 r9,[r6]
add r7,1
add r6,1

sub r8,1
bnz r8,:loop_drawhlineDiffuse

pop r5,r6,r7,r8,r9
mov pc,lr

;;################################################ ##################################################
;;## r0=dst r1=color r2=x r3=y r4=xsize
;;################################################ ##################################################
$drawhlineFlat
push r5,r6,r7,r8,r9
mov r8,r4 ; taille

; ajoute x
mov r6,r2

; ajoute y
mov r9,r3
shl r9,8
add r6,r9
mov r9,r3
shl r9,4
sub r6,r9

; méthode hard
;push r0
;DMAtoVRAM(r6, $void_unloop_bmp, r8)
;pop r0

; méthode soft
add r6,r0 ; pdst1 = x + y*240 + pdst0
:loop_drawhlineFlat
st8 r1,[r6]
add r6,1
sub r8,1
bnz r8,:loop_drawhlineFlat

pop r5,r6,r7,r8,r9
mov pc,lr

;;################################################ ##################################################
;;## r0=dst r1=color r2=x r3=y r5=ysize
;;################################################ ##################################################
$drawvlineFlat
push r6,r7,r8,r9
mov r8,r5 ; taille

; ajoute x
mov r6,r2

; ajoute y
mov r9,r3
shl r9,8
add r6,r9
mov r9,r3
shl r9,4
sub r6,r9

; méthode soft
add r6,r0 ; pdst1 = x + y*240 + pdst0
:loop_drawvlineFlat
st8 r1,[r6]
add r6,240
sub r8,1
bnz r8,:loop_drawvlineFlat

pop r6,r7,r8,r9
mov pc,lr


;;################################################ ##################################################
;;## partiellement débouclé, lent.
;;################################################ ##################################################
$screen_clear
push r0,r1,r2

mov r1,0 ; offset destination
mov r2,128 ; taille

:loop_clear_screen
DMAtoVRAM(r1, $void_unloop_bmp, 255)
add r1,255
sub r2,1
bnz r2,:loop_clear_screen

pop r0,r1,r2
mov pc,lr

;;################################################ ##################################################
;;## gradient (pas d'interruptions donc pas de donnés dans le registre DISPSTAT)
;;################################################ ##################################################
$pal_grad
push r0,r1,r2,r3

mov r0,PALETTE
mov r1,$grad_pal
ld8 r2,DISPSTAT
shl r2,5 ; *=32
add r0,r2
st r1,[r0]

pop r0,r1,r2,r3
mov pc,lr


;;################################################ ##################################################
;;## r0=dst r1=color r2=x r3=y
;;################################################ ##################################################
$draw_pixel
push r6,r7
mov r6,r0 ; dst

; ajoute x
add r6,r2

; ajoute y
mov r7,r3
shl r7,8
add r6,r7
mov r7,r3
shl r7,4
sub r6,r7

; ecriture
st8 r1,[r6]

pop r6,r7
mov pc,lr

Brunni
15/06/2009, 11h37
Sympa, merci d'avoir testé :)
Nan mais je de tte vais uploader une autre version avec au moins le CPU à 4 MHz plutôt que 8 kHz, parce que là c'est pour le test on peut vraiment rien faire à cette fréquence lol ^^
A 4 MHz par contre on ne voit rien, faudra utiliser le HALT (attente d'une interruption)...
Et juste un truc: le mode bitmap 8 bits a surtout été prévu à des fins de débogage, pas vraiment pour être utilisé étant donné qu'il n'y a pas de double buffering (pas assez de VRAM)... donc tu risques de t'embêter pour ton voxel, malgré la grosse période de VBLANK.

CrazyLapinou
23/06/2009, 23h06
Salut.
C'est vraiment pas mal ! J'avoue que je comprends pas grand chose... voir rien du tout :p J'ai pas tout lu, j'ai sauté les parties qui sont trop techniques (j'ai du lire les 10 première lignes :D)
Pour le CPU, tu as utilisé un existant, ou bien tu en as "inventé" un ?
Pourquoi ne pas se baser sur un PIC, AVR, ou tout autre microcontroleur qui pourrait servir de CPU ?
Ça pourrait éventuellement permettre de faire une partie hardware adapté ;)
Perso, je ne connais pas trop l'hardware non plus, mais je pense me lancer tranquillement. Donc si un jour tu veux adapter un hardware, ça serait sympa comme projet ;)
A bientôt et bonne chance pour la continuation.