Coder une cracktro sur Amiga (1/2)

A l'occasion de la sortie du deuxième documentaire de la formidable série From bedrooms to millions cette fois consacré à l'Amiga de Commodore, prenons un instant pour revisiter ce qui fut un des nombreux aspects de la scène de l'époque : la production de cracktros. Nous prendrons pour exemple une cracktro codée il y a un quart de siècle pour le célèbre groupe Paradox :
Cracktro sur Amiga
Cette cracktro a été choisie parce que son code a été retrouvé et qu'elle exploite le Blitter et le Copper, deux des coprocesseurs de l'Amiga dont il sera ainsi possible de souligner l'originalité de l'architecture.
Noter qu'il est possible de visualiser un enregistrement vidéo du résultat produit sur YouTube. La cracktro fait même partie de la sélection de portages en HTML5 de We Are Back, mais les auteurs de ce portage ont fait l'impasse sur l'effet le plus notable : l'utilisation d'une police à espace variable... Par contre, leurs homologues de Flashtro ont parfaitement réussi.
Cet article est le premier d'une série de deux. Après avoir rappelé comment mettre en œuvre un environnement de développement en assembleur 68000 dans le contexte d'un émulateur Amiga, il présentera un des deux coprocesseurs graphiques, le Blitter. Le second article présentera l'autre coprocesseur graphique, le Copper, et fera une petite synthèse sur l'intérêt que revenir sur le passé peut présenter aujourd'hui.
Cliquez ici télécharger le code (à peine un gros millier d'instructions en assembleur 68000) et les données de la cracktro.

Emuler l'Amiga dans Windows

WinUAE est l'émulateur par excellence pour faire revivre l'Amiga dans le contexte de Windows. Touefois, il ne suffit pas de le récupérer pour pouvoir l'utiliser. Il faut aussi se procurer la ROM de l'Amiga, le Kickstart (au moins dans la version 1.3). Par ailleurs, pour pouvoir compiler la cracktro, il faut installer le système d'exploitation, le Workbench (au moins dans la version 1.3). Kickstart et Workbench sont encore soumis à des droits. Ils sont commercialisés pour une dizaine d'euros par Amiga Forever.
Comme toute démonstration d'un savoir-faire en matière de programmation, de graphisme et de musique digne de ce nom à l'époque, la cracktro a été codée en assembleur 68000. Dans cet article, nous nous concentrerons sur la partie du code qui gère les effets graphiques (d'ailleurs, qu'il soit rendu à César ce qui revient à César : la partie qui joue la musique est "Coded by Lars 'ZAP' Hamre/Amiga Freelancers" et "Modified by Estrup/Static Bytes").
Il n'est pas question ici de s'étendre sur la manière d'utiliser WinUAE. L'objet de cet article est de rappeler ce que c'était que de programmer directement le hardware de l'Amiga et d'essayer d'en tirer quelques leçons pour aujourd'hui, pas d'inciter à programmer de nouveau de cette manière. Seuls les codeurs de l'époque pourraient être motivés à l'idée de compiler la cracktro à partir des données mises à disposition, et encore. Ceux-là seront nécessairement assez chevronnés pour s'y retrouver dans WinUAE et créer une configuration inspirée de l'Amiga 500 ou version ultérieure à mémoire étendue et émulation de disque dur. Les quelques informations fournies ci-après leur permettront de se rafraîchir la mémoire pour assurer la suite.
Cliquez ici pour télécharger le source et les données de la cracktro. Pour compiler le source et le lier avec les données au sein d'un exécutable, il vous faudra utiliser ASM-One :
Exécution de la cracktro en mode Debug dans ASM-One
Après avoir installé le Workbench sur une émulation de disque dur (le classique volume DH0:), vous devrez installer reqtools.library dans le répertoire Libs du système pour faire fonctionner ASM-One. Vous devrez aussi utiliser une commande du Shell pour assigner SOURCES: au répertoire contenant le code et les données (par exemple : assign SOURCES: DH0:cractros si vous avez déposé le contenu de l'archive dans un sous-répertoire cube qui s'y trouve). Après avoir lancé ASM-One, vous pourrez allouer un espace de travail en mémoire Fast de 100Ko par exemple, puis charger le source via la commande R (Read) et le compiler via la commande A (Assemble), non sans avoir spécifié qu'il faut ignorer la casse en vous rendant dans le menu Assembler, sous-menu Assemble pour activer l'option UCase=LCase. Vous pouvez ensuite enregistrer l'exécutable via la commande WO (Write Object).
Pour ce qui concerne la documentation, la référence de tout programmeur (nous dirons "codeur" pour revenir à l'esprit de l'époque) était l'Amiga Hardware Reference Manual (Addison-Wesley en avait édité une édition plus agréable à lire). Précis et clair car rédigé par les ingénieurs de Commodore eux-mêmes, le manuel donnait toutes les informations requises pour s'adonner à ce que d'aucuns dénonçaient comme du "metal bashing", c'est-à-dire la programmation directe du hardware en assembleur en court-circuitant le système d'exploitation, d'ailleurs totalement coupé le temps de l'exécution.
Tout codeur français qui a fait ses armes dans les années 80 sait la petite guerre qui a opposé les adorateurs de l'Atari ST et de l'Amiga, les deux machines 16 bits en concurrence sur le marché national. Objectivement, il faut noter que les capacités de l'Amiga dépassaient de loin celles de l'Atari ST dans le domaine du graphisme. C'est qu'en la matière, le codeur pouvait s'appuyer non seulement sur le processeur Motorola 68000, mais aussi et surtout sur deux coprocesseurs très puissants : le Blitter et le Copper.
Commençons par présenter le premier. Le Blitter est capable de copier des blocs et de tracer des droites. Son contrôle s'effectue en positionnant des bits de registres 16 bits résidant à des adresses spécifiques (par exemple, le registre de contrôle du Blitter BLTCON0 se trouve à l'adresse $DFF040 ou $00DFF040 pour être plus exact, le 68000 adressant la mémoire sur 32 bits).

Copier des blocs de mémoire

Dans sa fonction de copie, le Blitter peut lire mot par mot (16 bits) jusqu'à trois blocs à des adresses différentes (les sources A, B et C) et les combiner bit à bit. Pour chaque bloc, il faut spécifier l'adresse 32 bits du premier mot via les registres BLTxPTH et BLTxPTL correspondant au mot de poids fort et au mot de poids faible de l'adresse en question. Il faut aussi spécifier le modulo via le registre BLTxMOD, c'est-à-dire le nombre d'octets à ajouter à l'adresse du mot suivant le dernier mot d'une ligne du bloc pour adresser le premier mot de la ligne suivante. Par exemple, sur la figure suivante, le modulo est de 2 mots, soit 4 octets (la mémoire est un espace d'adressage linéaire, évidemment, mais le Blitter permet donc de se la représenter comme une surface) :
Adressage d'un bloc de mémoire par le Blitter
Une fois les adresses (obligatoirement paires) spécifiées, il suffit de spécifier la largeur et la hauteur des blocs (obligatoirement identiques) dans le registre BLTSIZE. Un bloc peut faire jusqu'à 1024x1024 bits (un peu moins en largeur si le décalage et le masquage présentés plus loin sont utilisés). L'unité étant le mot (ie : 16 bits), la hauteur est spécifiée à l'aide des bits 6 à 15 de BLTSIZE, et la largeur à l'aide de ses bits 0 à 5, soit en pseudo-code :
BLTSIZE = (hauteur << 6) + largeur.
BLTSIZE est un strobe, c'est-à-dire un registre dont le simple accès en écriture déclenche une action, en l'occurrence la copie attendue.
La combinaison des sources correspond à une combinaison par OR de huit combinaisons par AND des sources. Nécessairement assez complexe, la formule générale est spécifiée à l'aide d'une combinaison de bits de l'octet de poids faible d'un des registres 16 bits de contrôle du Blitter, BLTCON0 (X désigne le bit de la source X et x désigne le NOT de ce bit) :
Combinaison Bit de BLTCON0
abc 0
abC 1
aBc 2
aBC 3
Abc 4
AbC 5
ABc 6
ABC 7
Ainsi, le bit de la destination D résultant de la combinaison des bits issus des sources A, B et C est d'abord calculé de huit manières en combinant par AND des bits des sources éventuellement inversés :
Combinaison des sources par le Blitter
Puis ces huit versions de D sont combinées par OR pour déterminer le bit final :
Combinaison des combinaisons de sources par le Blitter
Ce fonctionnement général peut être raffiné, car les registres de contrôle du Blitter BLTCON0 et BLTCON1 permettent de spécifier bien d'autres choses :
  • Activer ou désactiver les sources A, B et C et la destination D (désactiver la destination permet de simuler une copie pour vérifier, via un bit de contrôle positionné par le Blitter, si elle n'a généré que des bits à 0 : un moyen pour tester sans calcul une collision au pixel près). En fait, toutes les sources sont toujours combinées et le résultat renvoyé sur la destination en transitant mot par mot par des registres de données BLTxDAT. Toutefois, en désactivant une source, il est possible de figer la valeur du registre de données correspondant, comme si le même mot était lu à une adresse fixe (sauf qu'il n'est donc pas lu en mémoire, étant simplement lu dans BLTxDAT dans lequel il est possible d'écrire le mot souhaité avant la copie).
  • Décaler de 0 à 15 bits sur la droite les mots lus aux sources A et B (pas C). Il s'agit d'un décalage par barillet, c'est-à-dire que tout bit sorti sur la droite du mot lu à l'adresse X est réintroduit sur la gauche du mot lu à l'adresse X + 2. Et pour le premier mot d'une ligne du bloc adressé par une source, direz-vous ? Des 0 sont introduits sur sa gauche.
  • Lire les blocs par adresse croissante ou décroissante. Cela permet d'écraser une source. Par exemple, pour remonter une image d'une ligne, il faut la recopier sur elle-même par adresse croissante : l'adresse initiale de la source est celle premier mot de la deuxième ligne tandis que l'adresse initiale de la destination est celle du premier mot de la première ligne, et ces adresses croissent simultanément (mode ascendant). A l'inverse, pour descendre l'image d'une ligne, il faut la recopier sur elle-même par adresse décroissante : l'adresse initiale de la source est celle du dernier mot de l'avant-dernière ligne tandis l'adresse initiale de la destination est celle du dernier mot de la dernière ligne, et ces adresses décroissent simultanément (mode descendant).
Adressage en mode ascendant ou descendant pour remonter ou descendre une image d’une ligne
Enfin, deux registres BLTAFWM et BLTALWM permettent de définir des masques à appliquer au premier mot et au dernier mot lus à la source A (pas B ni C). Pour quoi faire ? Pour détourer des sprites par exemple – des sprites logiciels, le Copper gérant des sprites, pour leur part matériels.
Dans la cracktro, une copie avec décalage réalisée à chaque trame par le Blitter permet de produire le scroll (un bloc à l'adresse calculée dans A1 est recopié sur lui-même en mode ascendant en décalant ses bits du nombre de bits correspondant à la vitesse du scroll en pixels par trame) :
lea $DFF000,a5
moveq #2,d1			;vitesse du scroll
ror.w #4,d1
bset #1,d1			;mode ascending
movea.l Screen1_adr,a1
add.w #(ScrollHeight+FontHeight)*NbPlane1*SizeX1/8-2,a1
move.w #%0000010111001100,BLTCON0(a5)
move.w d1,BLTCON1(a5)
move.w #$0000,BLTDMOD(a5)
move.w #$0000,BLTBMOD(a5)
move.l a1,BLTBPTH(a5)
move.l a1,BLTDPTH(a5)
move.w #SizeX1/16+64*FontHeight*NbPlane1,BLTSIZE(a5)
Pour le détail, c'est donc 11001100 qui est stocké dans l'octet de poids faible de BLTCON0 pour copier à l'identique les bits de la source B dans la destination. En effet, la combinaison logique mise en oeuvre est alors aBc + aBC + ABc + ABC, c'est-à-dire que le bit de la source B est retenu quelles que soient les valeurs des bits des sources A et C (par ailleurs désactivées).
Mais j'oubliais... En même temps qu'il copie des blocs de mémoire, le Blitter peut remplir ces derniers en positionnant tous les bits rencontrés sur une ligne. Dans ce mode, le Blitter ne fait rien tant que le bit lu n'est pas 1, positionne tous les bits lus par la suite jusqu'à lire un bit 1, et reboucle alors. Il est possible d'inverser ce fonctionnement, contraignant donc le Blitter à positionner les bits lus dès le début tant qu'il n'a pas lu un bit 1, ne rien faire jusqu'à lire un bit 1, et reboucler alors.
Remplissage normal et inversé par le Blitter
Il existe deux variantes de ce remplissage, l'une où les bits limitrophes à gauche sont maintenus (inclusive-fill) et l'autre où ces derniers sont effacés (exclusive-fill) :
Inclusive-fill et exclusive-fill du Blitter
L'exclusive-fill, pour quoi faire ? Pour permettre de produire des surfaces remplies très précises, où un sommet figurant sur une ligne n'était représenté que par un pixel et non les deux pixels juxtaposés qu'il faut bien dessiner avant de lancer le remplissage pour éviter que le Blitter ne remplisse la ligne n'importe comment.
Dans la cracktro, cette fonctionnalité est utilisée pour remplir les surfaces des cubes qui sont donc tracées en s'assurant de ne faire figurer qu'un point par ligne le long de chaque côté. Et pour tracer ces côtés, c'est... le Blitter qui est encore utilisé, comme nous allons le voir plus loin.
Pour terminer sur cette fonction de copie du Blitter, il faut noter que toutes les possibilités qui ont été évoquées (décalage, masquage, combinaison logique des sources et remplissage) peuvent être combinées sans pénalité, étant réalisées les unes après les autres dans un pipeline. Le manuel donne quelques conseils pour exploiter le remplissage du pipeline, mais il s'agit là d'un sujet particulièrement avancé – dont les ingénieurs de Commodore ne garantissaient par ailleurs pas la pérennité.

Tracer des droites

En positionnant un bit particulier de BLTCON0, il est possible de demander au Blitter non plus de copier un bloc en le remplissant ou non, mais de tracer une droite, d'au plus 1024 pixels, à l'aide d'un motif. Dans ce mode, le Blitter interprète différemment certains bits des registres BLTCON0 et BLTCON1.
Pour tracer une droite entre A (xA, yA) et B (xB, yB), il faut connaître :
  • les coordonnées du point de départ ;
  • l'octant du repère de centre A dans lequel se trouve B ;
  • les valeurs absolues des différences d'abscisses et d'ordonnées.
Octants permettant de préciser la position de points d'une droite au Blitter
Pour rajouter à l'ésotérisme, le Blitter utilise bien le numéro de l'octant, mais il faut lui fournir la pente de l'image de la droite dans l'octant 6. Pour cette raison, des grandeurs dx et dy qui sont utilisées plus loin doivent être calculées ainsi :
  • dx = max (abs (yB - yA), abs (xB - xA))
  • dy = min (abs (yB - yA), abs (xB - xA))
Registre(s) Usage
BLTCPTH et BLTCPTL Adresse du mot du bitplane contenant le bit correspondant au pixel A
BLTDPTH et BLTDPTL Idem
BLTAMOD 4 * (dy - dx)
BLTBMOD 4 * dy
BLTCMOD Largeur du bitplane en octets
BLTDMOD Idem
BLTAPTL (4 * dy) - (2 * dx)
BLTAFWM $FFFF
BLTALWM $FFFF
BLTADAT $8000
BLTBDAT Motif de la droite sur 16 pixels ($FFFF pour une droite pleine)
BLTCON0 La combinaison d'un certain nombre de bits qui doivent être positionnés par exigence système et de bits significatifs, ces derniers étant :
  • quatre bits correspondant à la position du pixel A dans le mot dont l'adresse est fournie via BLTCPTH/BLTDPTH et BLTCPTL/BLTCPTL ;
  • huit bits correspondant à la combinaison logique du pixel du masque fourni via BLTADAT, du motif fourni via BLTBDAT et de la destination tiré de BLCDAT.
BLTCON1 La combinaison d'un certain nombre de bits qui doivent être positionnés par exigence système et de bits significatifs, ces derniers étant :
  • quatre bits correspondant à la position du premier bit à utiliser pour tracer à la droite dans le motif fourni via BLTBDAT ;
  • un bit indiquant si (4 * dy) - (2 * dx) est négatif ;
  • trois bits donnant le numéro de l'octant ;
  • un bit indiquant si le Blitter ne doit tracer qu'un pixel par ligne de pixels dans le bitplane.
BLTSIZE ((dx + 1) << 6) + 2
Le tracé d'une droite est une opération de copie de bloc combinant trois sources A, B et C où C correspond au bitplane dans lequel la droite doit être tracée, A le bit à positionner dans le bitplane pour y tracer un pixel de la droite, B le motif qui doit déterminer si le pixel courant de la droite doit effectivement être tracé dans le bitplane. La combinaison des sources utilisée est donc AB + AC, mais d'autres peuvent être envisagées – notamment ABC + AB pour tracer une droite qu'il sera possible d'effacer en simplement la traçant de nouveau. Le tracé de droite est lancé comme une copie de bloc, en écrivant dans le registre BLTSIZE.
Dans la cracktro, après initialisation une fois pour toutes de certains registres (BLTALWM avait été oublié !)... :
move.w #$FFFF,BLTBDAT(a5)
move.w #SizeX0/8,BLTCMOD(a5)
move.w #SizeX0/8,BLTDMOD(a5)
move.w #$8000,BLTAFWM(a5)
move.w #$8000,BLTADAT(a5)
...le tracé de droite a été factorisé dans la routine suivante :
;*************** TRACE DE DROITES ***************

;Entree:
;	A5=$DFF000
;	A0=adresse bitplane
;	D0=Xi
;	D1=Yi
;	D2=Xf
;	D3=Yf

;A6,D5,D6

DrawLine:

;----- ordonnancement des points -----

	cmp.w d1,d3
	beq DrawLine_End
	bge DrawLine_UpDown
	exg d0,d2
	exg d1,d3
DrawLine_UpDown:
	subq.w #1,d3

;------ calcul adresse de depart de la droite -----
	
	moveq #0,d6
	move.w d1,d6
	lsl.l #3,d6
	move.l d6,d5
	lsl.l #2,d5
	add.l d5,d6		;d6=y1*nbre octets par ligne
	add.l a0,d6		;+adresse depart bitplane
	moveq #0,d5
	move.w d0,d5
	lsr.w #3,d5
	bclr #0,d5
	add.l d5,d6		;+x1/8

;----- recherche de l'octant -----

	moveq #0,d5
	sub.w d1,d3	;d3=Dy=y2-y1
	bpl.b Dy_Pos
	bset #2,d5
	neg d3
Dy_Pos:	
	sub.w d0,d2	;d2=Dx=x2-x1
	bpl.b Dx_Pos
	bset #1,d5
	neg d2
Dx_Pos:
	cmp.w d3,d2	;Dx-Dy
	bpl.b DxDy_Pos
	bset #0,d5
	exg d3,d2	;ainsi d3=Pdelta et d2=Gdelta
DxDy_Pos:
	add.w d3,d3	;d3=2*Pdelta

;----- BLTCON0 -----
	
	and.w #$F,d0
	ror.w #4,d0
	or.w #$B4A,d0

;----- BLTCON1 -----

	lea Octant_adr,a6
	move.b (a6,d5.w),d5
	lsl #2,d5
	bset #0,d5
	bset #1,d5

;----- attente blitter -----

	WAITBLIT

;----- BLTCON1, BLTBMOD, BLTAPTL, BLTAMOD -----

	move.w d3,BLTBMOD(a5)
	sub.w d2,d3
	bge.s DrawLine_NoBit
	bset #6,d5
DrawLine_NoBit:
	move.w d3,BLTAPTL(a5)
	sub.w d2,d3
	move.w d3,BLTAMOD(a5)

;----- BLTSIZE -----

	lsl #6,d2
	add.w #66,d2

;----- lancement blitter -----

	move.w d5,BLTCON1(a5)
	move.w d0,BLTCON0(a5)
	move.l d6,BLTCPTH(a5)
	move.l d6,BLTDPTH(a5)
	move.w d2,BLTSIZE(a5)

;----- fin -----

DrawLine_End:

	rts
Comme déjà mentionné, ce tracé de droite s'appuie sur une fonctionnalité du Blitter qui permet de limiter le tracé de la droite à un pixel par ligne de pixels dans le bitplane :
Tracé de contours de faces en vue de leur remplissage par le Blitter
Si toutes les faces étaient tracées dans une même surface, il est clair que le Blitter ne pourrait pas remplir chacune correctement. Dans la cracktro, une astuce tient compte du fait qu'un cube n'expose jamais plus que trois faces simultanément, deux à deux partageant un côté et un seul, pour produire un tracé que le Blitter peut remplir.
Soient A, B et C les trois faces visibles (qui peuvent n'être qu'une ou deux) à un instant donné. Dans un bitplane, A et C sont tracées en omettant soigneusement le côté qu'elles ont en commun, tandis que dans un autre bitplane, B et C sont tracées en prenant la même précaution.
Tracé des contours de surfaces contigües pour le remplissage par le Blitter
Dans ces conditions, il est possible de remplir chacun des bitplanes au Blitter sans difficulté si bien que :
  • les bits des pixels de A sont à 1 dans le premier bitplane et à 0 dans le second ;
  • les bits des pixels de B sont à 0 dans le second bitplane et à 1 dans le second ;
  • les bits des pixels de C sont à 1 dans les deux bitplanes.
Comme c'est la combinaison des bits d'un pixel figurant dans les bitplanes qui donne le numéro de la couleur dans laquelle ce pixel doit être affiché, chacune des faces peut ainsi être affichée dans une couleur particulière, moyennant un petit arbitrage attribuant les pixels du côté commun à A et B à l'une ou l'autre de ces faces – le remplissage est un exclusive-fill. Au final, il n'a ainsi fallu que deux opérations de remplissage au Blitter, et non trois, pour remplir trois faces de couleurs distinctes :
Coloration des faces visibles

Un fonctionnement en parallèle du CPU

Pour terminer sur le Blitter, il faut noter que fonctionnant en parallèle du processeur car disposant d'un accès direct en mémoire (DMA) - il est même possible d'interdire au CPU de lui voler des cycles d'accès à la mémoire -, il suffit de lancer une copie (avec ou sans remplissage) ou un tracé de lignes et reprendre comme si de rien n'était. In fine, il faut bien s'assurer que le Blitter avait terminé sa tâche, ce qui s'effectue en testant un bit du registre de contrôle DMACONR.
Dans la cracktro, ce test revient fréquemment si bien que pour s'éviter d'en faire une routine à laquelle il aurait fallu sauter puis revenir en perdant des cycles d'exécution, elle figure sous la forme d'une macro (le test est doublé pour une bonne raison présentée ici) :
WAITBLIT:	macro
Wait_Blit0\@
		btst #14,DMACONR(a5)
		bne Wait_Blit0\@
Wait_Blit1\@
		btst #14,DMACONR(a5)
		bne Wait_Blit1\@
		endm
D'une manière générale, il n'y avait pas de petites économies en cycles d'exécution pour "tenir dans la trame" (ie : s'assurer qu'une image était reproduite à chaque balayage de l'écran, soit 50 fois par seconde dans le monde PAL, pour produire une animation fluide à l'écran), ce qui conduisait à privilégier la répétition de code, donc les macros telles que WAITBLIT, sur les routines.
Coder une cracktro sur Amiga (1/2)