Coder un sine scroll sur Amiga (5/5)

Cet article est le cinquième et dernier article d'une série de cinq consacrés à la programmation d'un one pixel sine scroll sur Amiga, un effet très utilisé par les coders de démos et autres cracktros durant un temps. Par exemple, dans cette cracktro du groupe Angels :
Sine scroll dans une cracktro du groupe Angels
Dans le premier article, nous avons vu comment installer en environnement de développement sur un Amiga émulé avec WinUAE et coder la Copper list de base pour afficher quelque chose à l'écran. Dans le deuxième article, nous avons vu comment préparer une police de caractères 16x16 pour en afficher facilement les colonnes de pixels des caractères, précalculer les valeurs du sinus requises pour déformer le texte en modifiant l'ordonnée des colonnes, et mettre en place un triple buffering pour alterner proprement les images à l'écran. Enfin, dans le troisième article, nous avons vu comment dessiner et animer le sine scroll, d'abord au CPU, puis au Blitter. Dans le quatrième article nous avons vu comment enjoliver le sine scroll avec quelques effets peu coûteux en cycle assurés par le Copper.
Dans ce cinquième et dernier article, nous allons optimiser le code afin d'être bien certain de tenir dans la trame, et nous protéger des lamers tentés de modifier le texte. Pour terminer, nous verrons s'il n'y a pas quelques leçons à tirer de cette immersion dans la programmation en assembleur du hardware de l'Amiga.
Cliquez ici pour télécharger l'archive contenant le code et les données du programme présenté ici - c'est la même que dans les autres articles.
NB : Cet article se lit mieux en écoutant l'excellent module composé par Nuke / Anarchy pour la partie magazine de Stolen Data #7, mais c'est affaire de goût personnel...
Mise à jour du 12/07/2017 : Perfomances améliorées suite à la suppression du positionnement de BLTPRI dans DMACON.
Mise à jour du 27/10/2018 : Suite à la "découverte" d'une option oubliée dans WinUAE, rajout d'une section présentant des versions optimisées du sine scroll avec l'étoile.
Click here to read this article in english.

Précalculer pour tenir dans la trame

Notre sine scroll est au pixel, ce qui est mieux que celui du groupe Falon évoqué dans le premier article, mais il ne faut pas oublier que nous l'exécutons sur Amiga 1200 et non sur Amiga 500, c'est-à-dire sur un ordinateur bien plus rapide ! Pour savoir si notre code est performant, nous devons le tester sur un Amiga 500.
Pour cela, nous allons mettre l'exécutable sur disquette et booter à partir de cette dernière dans le contexte d'une émulation d'Amiga 500.
Dans ASM-One, utilisons les commandes en ligne A (Assemble) pour assembler, puis WO (Write Object) pour générer un exécutable et l'enregistrer dans SOURCES: sous le nom de sinescroll.exe. Rendons-nous alors dans le Workbench. Double-cliquons sur l'icône du lecteur DH0, puis sur celle du dossier System et enfin sur celle du Shell.
Pressons F12 pour accéder à la configuration de WinUAE. Dans la rubrique Hardware, cliquons sur Floppy drives. Cliquons sur Create Standard Disk pour créer une disquette formatée au format ADF. Cliquons ensuite sur ... à droite du lecteur DF0: et sélectionnons ce fichier pour simuler l'introduction de la disquette dans le lecteur. Cliquons enfin sur OK pour revenir au Workbench.
Dans le Shell, exécutons cette série de commandes pour commander l'exécution de sinescroll.exe lorsque nous booterons avec la disquette :
install df0:
copy sources:sinescroll.exe df0:
makedir df0:s
echo "sinescroll.exe" > df0:s/Startup-Sequence
L'archive mentionnée au début de cet article contient le fichier ADF qui correspond à la disquette ainsi préparée.
Créons alors une émulation d'Amiga 500 - nous aurons besoin du Kickstart 1.3. La chose faite, insérons la disquette dans le lecteur DF0: et démarrons la simulation en cliquant sur Reset. Le sine scroll se lance automatiquement.
Le résultat tourne tout juste dans la trame - pour ne pas être méchant en disant : pas dans la trame. Difficile de prétendre produire un sine scroll d'aussi belle hauteur que celui de Falon dans ces conditions ? Bah !, nous pourrions recourir à une astuce. Sans la documenter ici, elle consisterait à doubler les lignes à peu de frais, en demandant au Copper de modifier les modulos à chaque ligne afin de répéter la ligne du dessus une ligne sur deux. Le résultat perdrait en finesse, mais il pourrait tromper son monde.
Il resterait toujours à optimiser le code pour tenir dans la trame. Ce dernier ayant été écrit sans réfléchir à la performance, il ne faudrait pas trop se creuser la tête pour trouver les moyens de réaliser de jolis gains de temps.
A cette fin, il faudrait commencer par se référer non seulement au M68000 8-/16-/32-Bit Microprocessors User's Manual, qui détaille le nombre de cycles d'horloge pris par une instruction selon la variante qui en est utilisée, mais aussi à l'Amiga Hardware Reference Manual, qui explique la manière dont le CPU et les différents coprocesseurs disposant d'accès DMA se partagent les cycles d'accès à la mémoire durant le tracé d'une ligne - la belle figure 6-9 du manuel.
Il faudrait ensuite travailler sur l'algorithme pour parvenir à un code performant au regard des consommations de cycles qui viennent d'être évoquées. Comme toujours, le premier réflexe devrait être de chercher à sortir de la boucle principale tout ce qui peut être précalculé, du moment que la mémoire pour stocker des précalculs est disponibles.
Par exemple, il est possible de précalculer l'ordonnée de chaque colonne pour toutes les valeurs de l'angle variant entre 0 et 359 degrés. Ainsi, lors de l'affichage d'une colonne, le code exécuté à chaque itération de la boucle principale n'est plus... :
	lea sinus,a6
	move.w (a6,d0.w),d1
	muls #(SCROLL_AMPLITUDE>>1),d1
	swap d1
	rol.l #2,d1
	add.w #SCROLL_Y+(SCROLL_AMPLITUDE>>1),d1
	move.w d1,d2
	lsl.w #5,d1
	lsl.w #3,d2
	add.w d2,d1
	add.w d6,d1
	lea (a2,d1.w),a4
...mais :
	move.w (a2,d0.w),d4
	add.w d2,d4
	lea (a0,d4.w),a4
Ou encore, il est possible d'analyser le texte avant la boucle pour créer une liste des colonnes auquel ce texte correspond. Cette fois, c'est une vingtaine de lignes exécutées à chaque itération de la boucle principale qui sont d'un coup remplacées par les quelques suivantes :
	cmp.l a1,a3
	bne _nextColumnNoLoop
	movea.l textColumns,a1
_nextColumnNoLoop:
Après avoir épuisé les précalculs, il est possible d'intervenir sur le code. Par exemple, pour supprimer le double test d'attente du Blitter... :
_waitBlitter0\@
	btst #14,DMACONR(a5)
	bne _waitBlitter0\@
_waitBlitter1\@
	btst #14,DMACONR(a5)
	bne _waitBlitter1\@
...ce qui donne :
_waitBlitter0\@
	btst #14,DMACONR(a5)
	bne _waitBlitter0\@
Ou encore, pour stocker à l'avance $0B4A dans le registre de données du CPU (ici, D3) utilisé pour alimenter BLTCON0 lorsqu'une colonne est tracée au Blitter... :
	move.w d3,d7
	ror.w #4,d7
	or.w #$0B4A,d7
	move.w d2,BLTCON0(a5)
...ce qui donne (pour passer au pixel suivant, ajouter $1000 à D3 et non plus 1, et tester le drapeau C du registre des conditions internes du CPU par BCC pour détecter un dépassement du 16ème pixel, lequel entraîne une réinitialisation D3 à la valeur voulue $0B4A qu'il est donc inutile de demander !) :
	move.w d3,BLTCON0(a5)
Le source de cette version optimisée correspond au fichier sinescroll_final.s qui se trouve dans l'archive mentionnée au début de cet article.
En bonus, ce source contient un code qui détermine le nombre de lignes parcourues par le faisceau d'électrons entre le début et la fin des calculs d'une trame. Ce code affiche ce nombre en décimal en haut à gauche - en PAL, c'est-à-dire à 50Hz, le faisceau d'électrons parcourt 313 lignes. Pour visualiser ce temps pris par les calculs, la couleur 0 est passée en rouge au début de cette période et en vert à sa fin.
Il est ainsi possible de constater que sur Amiga 500, il faut 138 lignes pour afficher le sine scroll dans la trame (à gauche), alors que sur Amiga 1200 (à droite), il en faut seulement 54 :
Temps par trame pris par la version optimisée sur Amiga 500 Temps par trame pris par la version optimisée sur Amiga 1200
Le gain généré par cette optimisation est important, mais sans doute plus limité, sur Amiga 1200 où le nombre de lignes passe de 62 à 54, soit un gain de 13% - pour information, le nombre de lignes d'une version où les colonnes sont tracées au CPU, et non au Blitter, passe de 183 à 127 lignes après optimisation, soit un gain de 31% !
Toute économie est toujours bonne à prendre, mais il ne faut pas perdre de vue qu'un précalcul immobilise toujours de la mémoire et génère une attente pour l'utilisateur si le résultat de ce précalcul n'a pas été stocké sous forme de données liées au code dans l'exécutable. En l'occurrence, précalculer les colonnes de la totalité du texte conduit à immobiliser 32 octets par caractère, soit 34 656 octets pour les 1 083 caractères de notre texte. Bon, cela reste raisonnable.
Ainsi, le sine scroll ne tenait pas dans la trame sur Amiga 500. Désormais, il reste largement assez de temps pour l'enjoliver ! Ne nous en privons pas, et sans qu'il soit question de détailler le code que cela implique - le source correspond au fichier sinescroll_star.s qui se trouve dans l'archive mentionnée au début de cet article -, rajoutons pour finir une étoile vectorielle qui tourne dans le fond, avec ombre projetée et reflet dans le miroir comme le sine scroll, ces effets ne coûtant pas plus :
Avec une animation vectorielle, c'est mieux...
Pour afficher le tout, il faut 219 lignes sur Amiga 500, et 103 sur Amiga 1200, sans aucune optimisation - en particulier, le remplissage n'est pas limité à la zone qu'occupe l'étoile, ce qui fait perdre beaucoup de temps sur Amiga 500. Nous pourrions facilement démultiplier la hauteur du sine scroll en répétant des lignes à l'aide d'un jeu sur le modulo au Copper, rajouter un starfield à base de sprites hardware répétés au Copper, agrémenter l'effet avec un beau module de Monty, etc. Mais c'est une autre histoire...

Se protéger des lamers

Un lamer pourrait ripper notre beau sine scroll ! En particulier, il pourrait utiliser un éditeur hexadécimal pour modifier le texte qui défile. Pour se protéger de ce lamer, adoptons une protection de base dont il fera les frais s'il entreprend de s'y attaquer.
Pour que le texte ne soit pas apparent, nous devons l'encoder. Contentons-nous de combiner les octets de ses caractères par XOR avec TEXT_XOR, un octet de valeur quelconque. Ainsi, les caractères n'apparaissent pas comme des caractères dans un éditeur hexadécimal.
Et si tout de même le lamer devait deviner l'opération - ce dont nous lui laissons délibérément la possibilité en exposant le texte encodé à une attaque fondée sur l'analyse de récurrences -, calculons TEXT_CHECKSUM, un checksum du texte encodé, et rajoutons ici et là des appels à un code qui vérifie que le texte n'a pas été altéré. Ce code calcule le checksum du texte courant et le remplace par "You are a LAMER!" (de checksum TEXT_CHECKSUM_LAMER) si ce checksum ne correspond à aucun des checksums de nos désormais deux textes originaux :
La punition du lamer qui modifierait le texte du sine scroll
Ne factorisons pas ce code, mais répétons-le, pour en demander l'exécution en divers endroits de sorte que le lamer ne puisse s'en débarrasser en substituant simplement un RTS à la première instruction de ce qui constituerait autrement son unique occurrence.
;Attention au contexte dans lequel la macro est utilisée, car elle peut modifier la longueur du texte initial (qui doit être au moins aussi long que "You are a LAMER!" sous peine d'écraser des données) et donc embrouiller le code qui était en train de le parcourir

CHECKTEXT:	MACRO
	movem.l d0-d1/a0-a1,-(sp)
	lea text,a0
	clr.l d0
	clr.l d1
_checkTextLoop\@
	move.b (a0)+,d0
	add.l d0,d1
	eor.b #TEXT_XOR,d0
	bne _checkTextLoop\@
	cmp.l textChecksum,d1
	beq _checkTextOK\@
	move.l #TEXT_CHECKSUM_LAMER,textChecksum
	lea text,a0
	lea textLamer,a1
_checkTextLamerLoop\@
	move.b (a1)+,d0
	move.b d0,(a0)+
	eor.b #TEXT_XOR,d0
	bne _checkTextLamerLoop\@
_checkTextOK\@
	movem.l (sp)+,d0-d1/a0-a1
	ENDM
Très easter egg. On est jeune, on rigole.

Quelques mots pour conclure

Coder en assembleur 68000 est un travail exigeant. Le nombre important de registres disponibles et le souci permanent d'en optimiser l'usage conduit le codeur à empiler et dépiler dans sa propre mémoire l'usage qu'il en fait tandis qu'il progresse dans l'écriture du code. Dans mon souvenir, celui qui code en assembleur 80x86 est moins confronté à cette exigence, car le nombre des registres est si faible et leurs usages tellement contraints qu'il est indispensable de s'appuyer sans cesse sur la pile du CPU, pile dont il est plus facile de se souvenir du contenu que de celui de 13 registres.
Je n'étais pas parti dans l'idée de me remettre à coder sur Amiga lorsque j'ai entrepris de revisiter le code d'une cracktro dans la série d'articles précédents. C'est en relisant l'Amiga Hardware Reference Manual que je me suis rappelé que je n'avais jamais poussé bien loin l'étude de la manière dont le Blitter trace des lignes, fonctionnalité que je savais être utilisée pour produire notamment un sine scroll. Finalement, j'ai voulu clarifier les choses, et j'ai programmé à partir de rien cet effet.
Plus généralement, en feuilletant ce manuel mais aussi ceux du 68000, j'ai pu constater combien j'étais resté sur une vision très superficielle du fonctionnement du hardware et du CPU à l'époque. Si j'ai donc une leçon à formuler, c'est que chaque fois qu'on s'intéresse à une technologie, il faut se donner la peine de lire scrupuleusement l'intégralité de sa documentation de référence plutôt que de se contenter, par pure fainéantise, de s'en remettre à son intuition.
C'est qu'à ce régime, on prend non seulement le risque de manquer des fonctionnalités importantes, mais de plus celui d'en mal comprendre certaines. Par exemple :
	btst #14,$dff002
De prime abord, cette instruction teste le bit 14 du mot se trouvant à l'adresse $DFF002. En fait, la lecture de la description de BTST dans le M68000 Family Programmer's User Manual révèle que lorsque le premier opérande est N et le second est une adresse, c'est le bit N%8 (ie : N modulo 8) de l'octet se trouvant à l'adresse qui est testé. En l'espèce c'est donc le bit 14%8=6 de l'octet se trouvant à l'adresse $DFF002 qui est testé. Cela correspond bien au bit 14 de l'octet de poids fort du mot se trouvant à cette adresse, si bien que notre intuition se révèle pertinente. Toutefois, c'est par chance. Présumer ainsi de certains fonctionnements peut générer des erreurs d'autant plus difficiles à corriger qu'on est loin de soupçonner où elles se logent.
La lecture de la documentation de référence s'impose donc toujours comme un préalable difficilement contournable pour qui souhaite maîtriser véritablement une technologie. Et je dis bien la documentation de référence dans le texte, et non une de ses formes vulgarisées. C'est qu'au prétexte de rendre un savoir accessible, la vulgarisation prend trop souvent des libertés avec ce dernier, empruntant des raccourcis et faisant des impasses qui ne font que fourvoyer le talent et encourager la médiocrité. Une forme vulgarisée d'une documentation de référence ne doit jamais être considérée que comme un point d’entrée sur cette dernière. Elle ne saurait dispenser d’au moins en tenter la lecture, quand bien même cette entreprise peut se révéler ardue. C'est que la tendance est malheureusement plus aux exposés formels que didactiques - les auteurs des spécifications des technologies du Web auraient tout à gagner à lire l'Amiga Hardware Reference Manual !
Ce sera tout pour cette fois, et sans doute pour toujours en ce qui concerne la programmation en assembleur de l'Amiga - à laquelle je ne m'étais pas adonné depuis bientôt un quart de siècle. Je dédie ce travail à un vieux pote, Stormtrooper, sans la motivation duquel je n'aurais jamais entrepris de me mettre au metal bashing à l'époque, et à tous ceux dont les pseudos défilent dans les inévitables greetings que pourront lire les courageux qui assembleront le source de ce sine scroll. "Amiga rulez!"

Optimiser pour être "cycle-exact"

A l'occasion de la production d'une craktro récemment, il m'est apparu que j'avais oublié d'activer une option de WinUAE permettant une émulation exacte du hardware. Il s'agit de l'option Cycle-exact (full), dont l'activation entraîne celle de l'option Cycle-exact (DMA/Memory access) :
"Cycle-exact", la petite option qui vous perdra...
L'activation de ces options est indispensable pour espérer programmer fidèlement pour tout Amiga. A défaut, le CPU émulé dispose de beaucoup plus de cycles qu'il n'en dispose en réalité, car il ne se fait pas voler des cycles par le DMA. Autrement dit, le résultat observé dans WinUAE a toutes les chances d'être bien plus rapide qu'il ne le serait sur un Amiga bien réel.
C'est ce que j'ai pu constater dans le cas du sine scroll, fort heureusement uniquement quand l'étoile est rajoutée. Sur Amiga 1200, j'ai corrigé ce problème de la manière suivante :
  • en limitant la zone remplie au Blitter dans le bitplane de l'étoile au rectangle englobant cette dernière ;
  • en limitant la zone effacée dans le bitplane du sine scroll à la bande occupée par ce dernier, et en l'effaçant au CPU pendant que le Blitter remplit le bitplane de l'étoile ;
  • en limitant la zone effacée dans bitplane de l'étoile au rectangle englobant cette dernière, et en l'effaçant aussi CPU pendant que le Blitter est toujours en train de remplir le bitplane de l'étoile.
Cliquez ici pour récupérer le source. Le temps pris par une itération de la boucle principale s'évalue alors à 240 lignes, ce qui laisse assez de marge pour rajouter de la musique.
Sur Amiga 500, cette optimisation par la parallélisation ne permet toujours pas de tenir dans la trame. La seule solution est donc de précalculer les images de l'étoile, et de copier l'image courante dans le bitplane de l'étoile au Blitter. Cette dernière étant un motif périodique, il est possible de se contenter de précalculer 360 / 5 = 72 images. Ce nombre est à diviser par la vitesse de rotation de l'image, sous condition que 72 en soit un multiple.
Cliquez ici pour récupérer le source. Le temps pris par une itération de la boucle principale s'évalue alors à 242 lignes - ce qui comprend aussi l'effet d'une limitation la zone effacée au Blitter dans le bitplane du sine scroll à la bande occupée par ce dernier -, ce qui laisse assez de marge pour rajouter de la musique. Bien évidemment, cette version tourne encore plus rapidement que la précédente sur Amiga 1200, puisqu'elle le temps pris par une itération de la boucle principale est alors réduit à 183 lignes :
Temps par trame pris par la version avec étoile optimisée sur Amiga 500 Temps par trame pris par la version avec étoile optimisée sur Amiga 1200
Une conséquence de la "découverte" de l'option, c'est que les temps indiqués plus haut dans l'article sont donc faux. Le sine scroll tient bien dans la trame sur Amiga 500 et Amiga 1200, mais une itération de la boucle principale prend nettement plus de temps que ce qui avait été indiqué, à savoir 183 lignes sur Amiga 500 et 136 lignes sur Amiga 1200 :
Temps par trame pris réellement par la version optimisée sur Amiga 500 Temps par trame pris réellement par la version optimisée sur Amiga 1200
Ainsi, tout est remis en ordre ! Toutes mes confuses.