Scoopex « THREE » : Le coding-of d’un menu de trainer sur Amiga

Sur Amiga, le trainer était essentiellement un menu enjolivé par un FX et une musique, qui permettait d'activer des options pour tricher dans un jeu : "Vie illimitées: On/Off", et ainsi de suite. Dans bien des cas, le trainer a pu être le seul moyen de parvenir à profiter totalement d'un jeu sans y passer trop de temps, vu la difficulté. Ne termine pas Shadow of the Beast qui veut...
Dans la continuité d'un programme d'hommages aux différentes figures de la scène, voici Scoopex "THREE", un trainer produit pour le fameux StingRay du glorieux groupe Scoopex. Après Scoopex "TWO" rendant hommage aux graphistes, un hommage aux crackers, donc.
Scoopex THREE : Un trainer pour A500 en 2019
Comme on pourra le constater, l'originalité a pris ici le pas sur la technique. Du moins en apparence, car en matière de programmation du hardware en assembleur sur Amiga, tout devient finalement assez technique rapidement !
Code, data et explications dans tout ce qui suit...
Mise à jour du 08/12/2019 : Le menu a été magnifiquement porté sur Flashtro. C'est ici !
Ce menu n'a toujours pas été utilisé par StingRay, mais comme dans le cas de Scoopex "ONE" j'ai jugé qu'après des mois d'attente, il était nécessaire... de ne plus attendre. A priori, il devrait être utilisé sous peu. On verra bien...
A écouter en lisant l'article...
Cliquez ici pour télécharger l'archive contenant le code et les données du trainer.
Précisons d'emblée deux choses pour éviter les déceptions :
  • le code est celui du menu du trainer, et pas du trainer à proprement parler, au sens du code qui altère le code du jeu alors qu'il est exécuté ;
  • le tileset n'est pas compris, mais je donne des instructions à la fin de cet article pour le recréer.

Le design

Depuis le jour où j'ai fait la découverte émerveillée d'Ultima III: Exodus sur l'affichage CGA d'un PC1512, je voue une admiration sans borne à Richard Garriott, dit "Lord British".
Inutile de dire que cette admiration s'est renforcée avec la découverte, cette fois sur Amiga, de Ultima IV: Quest of the Avatar puis de Ultima V: Warriors of Destiny. Je ne peux contempler les cartes en tissus de ces jeux - oui, je ne suis pas un pirate : j'ai les originaux, môssieur ! - sans écraser une larme en souvenir des heures joyeuses passées à explorer les recoins de Britannia. C'est dire.
Il m'a toujours semblé que le potentiel de ces jeux a été sous-estimé, tout particulièrement le quatrième opus. Enfin diable ! ne présentait-il pas un caractère unique du fait qu'il contraignait le joueur à se plier à une éthique pour gagner ? Par la suite, je n'ai pas rencontré de jeux conçus de la sorte, et j'ai toujours pensé qu'un jour, le jeu vidéo en général trouverait son utilisation en tant qu'outil de propagande. D'ailleurs, j'avais développé ce propos dans un papier programmatique intitulé Le jeu vidéo au service de la communication, dans lequel il faudrait que je me replonge. Ceci étant, c'était en 2001, avant que l'histoire ne montre que les réseaux sociaux constituent un vecteur bien plus facile à utiliser...
Mais revenons à nos moutons. De toute cette série, c'est Ultima V qui a toujours eu ma préférence. Cela tient non seulement à la richesse du scénario élaboré par Richard Garriott, mais aussi à la beauté des graphismes produits par Denis Loubet et de Doug Wike :
Ultima V sur Amiga
Pas la musique ? Non, car sur ce point, je dois dire que sur Amiga, nous n'avons pas été gâtés. Je me souviens qu'après avoir attendu des mois, si ce n'est des années, que le jeu sorte enfin sur ma machine préférée, pour en faire l'acquisition à la Fnac - je me vois encore prendre la boîte dans le rayon aux Halles -, j'avais constaté avec dépit que les musiques n'étaient pas celles que j'avais pu écouter sur Atari ST. Au lieu de plusieurs morceaux tous plus géniaux les uns que les autres, il n'y avait qu'un morceau, certes pas mal, mais faisant perdre au jeu une grande part de sa variété. Enfin, bref, du moins le jeu n'était-il pas bogué comme sur Atari ST, et j'avais tout de même pu me foutre de la gueule d'un bon copain possédant cette machine quand, après ce temps d'attente qu'il n'avait manqué de me faire sentir comme le signe de la prévalence de sa machine sur la mienne, j'avais pu me vanter d'avoir terminé le jeu, et pas lui. Enfants...
Tout cela pour dire qu'il fallait rendre hommage à Ultima V avant de passer outre.
Or j'échange un jour avec le fameux StingRay du glorieux groupe Scoopex, et voici qu'il me propose de réaliser un trainer. Pour ceux qui l'ignorent, StingRay est l'un de ces brillants coders qui contribuent à la préservation du patrimoine de l'Amiga en reprenant méthodiquement les jeux pour en produire des versions qui peuvent être exécutées depuis un disque dur avec WHDLoad, avec trainer quand ils en étaient jusqu'alors dépourvus. Par exemple, Ultima V, pas plus tard qu'en avril 2018. Il faut disposer de l'original.
Je cogite un instant, et il me vient l'idée d'un trainer pour le moins original, puisqu'il consisterait à doubler l'inévitable menu non pas d'un FX, mais de toute une animation à base de tiles d'Ultima V. Il s'agirait de suivre un joueur qui se baladerait gentiment, avant que des streums lui tombent dessus. Là-dessus, il se ferait défoncer grave la gueule, et implorerait pitié pour être trainé par StingRay. Bam ! Il hériterait de la totale, et vêtu des pieds à la tête d'un matos de ouf, il mettrait une rouste aux affreux. C'est le pitch.
L'idée plaît à StingRay pour son originalité, et je me lance dans la réalisation, avec d'autant plus d'intérêt que j'étais en train de développer un Ultima-like à base de JavaScript et de WebGL - que je finirai bien par terminer un jour ? Du moins ce trainer m'aura-t-il déjà donné l'occasion de coder tout un éditeur de cartes à base de tiles, comme on le verra plus loin.

Le menu

Sur la base de l'expression des besoins de StingRay, le trainer peut proposer au joueur deux types de paramètres en pagaïe :
  • une valeur entière, par défaut comprise dans l'intervalle [0, 255], mais qu'il est possible de borner dans un intervelle quelconque plus petit ;
  • une valeur booléenne.
Par ailleurs, tout paramètre peut avoir une quelconque valeur par défaut.
La petite difficulté était de permettre à StingRay de facilement rédiger le contenu des pages du menu. Le mieux était d'être aussi WYSIWYG que possible, et donc de lui permettre d'entrelacer des lignes de texte, des lignes de paramètres, mais aussi des lignes permettant de naviguer dans le menu : aller à l'éventuelle page précédente, à l'éventuelle page suivante, quitter le trainer.
Pour cette raison, les lignes du menu sont déclarées dans les données sous la forme de structures :
<text>, 0, PARAM_NONE Simple ligne de texte
<text>, 0, PARAM_BOOL, <value> Ligne d'un paramètre booléen (0 : vrai, -1: faux)
<text>, 0, PARAM_INT, <value>, <min>, <max> Ligne d'un paramètre entier borné
<text>, 0, PARAM_PREV Ligne d'un lien vers la page précédente
<text>, 0, PARAM_NEXT Ligne d'un lien vers la page suivante
<text>, 0, PARAM_QUIT Ligne d'un lien pour quitter le trainer
0, PARAM_NONE Ligne vide
Par exemple, pour la première page - chaque page se termine par un -1 et l'ensemble des pages se termine par un -1 supplémentaire :
	DC.B 0,PARAM_NONE
	DC.B "  .oO Idea & Opcodes: Yragael Oo.",0,PARAM_NONE
	DC.B "oO. Paintings: Loubet &| Wike .Oo",0,PARAM_NONE
	DC.B "  .Oo    Bells & Horns: JMD   oO.",0,PARAM_NONE
	DC.B 0,PARAM_NONE
	DC.B "The menu may contain lines of",0,PARAM_NONE
	DC.B "text (32 chars), possibly empty,",0,PARAM_NONE
	DC.B "even between params.",0,PARAM_NONE
	DC.B 0,PARAM_NONE
	DC.B "There may be up to 255 params.",0,PARAM_NONE
	DC.B "A param is either BOOL or INT:",0,PARAM_NONE
	DC.B 0,PARAM_NONE
	DC.B "BOOL parameters:",0,PARAM_BOOL,-1
	DC.B "INT parameters:",0,PARAM_INT,0,0,10
	DC.B 0,PARAM_NONE
	DC.B "The menu may run on several pages.",0,PARAM_NONE
	DC.B "Just add PREV/NEXT option as",0,PARAM_NONE
	DC.B "required:",0,PARAM_NONE
	DC.B 0,PARAM_NONE
	DC.B "Next page",0,PARAM_NEXT
	DC.B 0,PARAM_NONE
	DC.B "And don't forget a QUIT option:",0,PARAM_NONE
	DC.B 0,PARAM_NONE
	DC.B "Quit",0,PARAM_QUIT
	DC.B -1
Au démarrage du trainer, les données qui décrivent ainsi le menu se trouvent à l'adresse menuData. Elles sont interprétées par la routine _menuSetup, qui génère une représentation du menu facile à manipuler à l'adresse menuPages, sous la forme d'une suite de structures de données dans le détail de laquelle il est inutile de rentrer. En particulier, ces structures sont utilisées par la routine _menuPrintPage pour afficher le contenu d'une page.
Une page du menu mobilise les bitplanes 5 et 6. Sur Amiga, le bitplane 6 est particulier, car le bit 5 qu'il introduit dans le codage de l'indice de la couleur d'un pixel ne permet pas d'afficher ce pixel dans une couleur qui peut être contrôlée. Le hardware considère que les couleurs 32 à 63 sont nécessairement des versions half-bright - moitié moins lumineuses - des couleurs 00 à 31. Ce fonctionnement est idéal pour le menu, car cela permet de l'afficher sur un fond qui laisse entrevoir les bitplanes de la première cut scene en semi-transparence sans avoir à intervenir dessus.
Le menu du trainer gère les booléens et les entiers bornés (ou non)
S'il est donc parfait que la luminosité des couleurs des pixels de la première cut scene soit atténuée, il ne faut pas oublier que le bitplane 6 affecte la luminosité des autres pixels, c'est-à-dire ceux du texte du menu. C'est pourquoi la routine _menuPrintPage, après avoir rempli de bits 1 le fond du menu dans le bitplane 6, écrit chaque caractère du menu dans le bitplane 5, et l'inverse de ce caractère dans le bitplane 6.

La souris

De toutes les difficultés auxquelles le codeur du hardware de l'Amiga peut être confronté, la gestion de la souris est l'une des plus inattendues. C'est qu'autant il est simple de tester si le bouton gauche de la souris est pressé ou relâché - tester le bit 6 de $BFE001 -, autant il est ardu de tester la même chose pour le bouton droit.
La lecture de cette section de l'Amiga Hardware Reference Manual permet de supposer que le bouton droit ne se gère pas comme le bouton gauche, car il n'est pas relié à une circuiterie du même genre : il est connecté à une broche analogique et non numérique.
De fait, à la lecture de cette annexe, il apparaît que lire l'état du bouton droit sur cette broche ne consiste pas simplement tester un bit dans un registre. S'il est bien possible de tester le bit 10 (DATLY) de POTGOR, ce n'est qu'après avoir demandé au hardware de traiter la broche comme numérique. Pour cela, il faut écrire dans un autre registre, POTGO. D'où la séquence suivante :
	move.w #$8400,POTGO(a5)	;OUTRY=1, DATLY=1
	btst #10,POTGOR(a5)		;Bit 10 % 8 = 2 de l'octet de poids fort, donc DATLY
L'annexe semble préciser qu'il faut en théorie attendre jusqu'à 300 ms entre les deux opérations. Toutefois, il n'apparaît pas que ce soit véritablement nécessaire. En tout cas, le code du trainer s'en dispense.
Et pour suivre le mouvement de la souris ? Comme bien expliqué ici, déplacer la souris entraîne l'incrémentation d'un compteur quand elle est déplacée vers le haut, et la décrémentation du même compteur quand elle est déplacée vers le bas. Il s'agit d'un compteur 8 bits, dont la valeur peut être lue dans JOY0DAT quand la souris est branchée sur le port 1 - ce qui généralement le cas.
Les précisions apportées ici permettent de comprendre qu'arrivé à saturation, le compteur reboucle. Le trainer en tient compte pour éviter produire cet effet indésirable où le mouvement de la souris vers le bas est interprété comme un mouvement vers le haut, et inversement. Par exemple, suite à un mouvement vers le bas, le passage de -126 à -127 permettrait bien de détecter une variation de -1 du compteur, assimilée à un mouvement vers le bas en tant qu'elle est négative. Toutefois, au mouvement suivant toujours vers le bas, le passage de -127 à 0 du compteur conduirait à détecter une variation de +127, assimilée à un mouvement vers le haut, en tant que cette variation est positive.
Dans le code du trainer, sachant que le compteur est lu à chaque trame - 1/50ème de seconde -, et qu'un mouvement ne peut raisonnablement faire reboucler le compteur s'il démarre à 0, le compteur est réinitialisé à 0 à chaque fois qu'il est lu. Par ailleurs, un mouvement n'est détecté que si le compteur dépasse le seuil MENU_MOUSESENSITIVITY - qui en pratique est fixé au minimum. La réinitialisation s'effectue en écrivant dans un autre registre, JOY0TEST :
	move.w JOY0DAT(a5),d1
	and.w #$FC00,d1		;Les deux bits de poids faibles doivent être ignorés
	beq _menuMoveSelectorExit
	bgt _menuMoveSelectorDown
	cmpi.w #-MENU_MOUSESENSITIVITY,d1
	bgt _menuMoveSelectorDone
	;Rajouter ici le code pour gérer un mouvement vers le haut
	bra _menuMoveSelectorDone
_menuMoveSelectorDown:
	cmpi.w #MENU_MOUSESENSITIVITY,d1
	blt _menuMoveSelectorDone
	;Rajouter ici le code pour gérer un mouvement vers le bas	
_menuMoveSelectorDone:
	move.w #0,JOYTEST(a5)
_menuMoveSelectorExit:
Il peut paraître curieux d'ignorer les deux bits de poids faible du compteur, car cela apparaît comme une perte de précision. C'est que comme expliqué ici, ces deux bits ont un comportement très particulier pour permettre de lire dans JOY0DAT aussi bien l'amplitude d'un mouvement de la souris, que la direction d'une poussée sur un joystick. Par conséquent, le mieux est de les ignorer.
Cliquez ici pour récupérer le code minimal permettant de prendre le contrôle du hardware pour afficher un écran sur un bitplane, et exécuter une boucle qui teste la pression des boutons de la souris, les mouvements de cette dernière, et le relâchement d'une touche (ESC) pour quitter.

Les cut scenes

Comme je l'ai mentionné, cela fait longtemps que j'essaie de produire un Ultima-like en JavaScript et WebGL - mais "Je l'aurais un jour ! Je l'aurais !", c'est sûr. Ce projet personnel m'a donné l'occasion de développer pas mal d'outils - c'est d'ailleurs bien le problème, cette perpétuelle digression... -, et d'acquérir une très bonne connaissance de JavaScript et de certaines des API du navigateur, dont WebGL et Canvas. Partant, je savais que si je me lançais dans le développement d'un éditeur de cut scenes pour le trainer, je n'aurais aucune difficulté pour le terminer rapidement.
Or composer le décor et décrire l'animation d'une cut scene à l'aide de codes dans un éditeur de texte allait assurément vite se révéler d'autant plus fastidieux, que je n'étais pas du tout certain de la cut scene à créer, si bien qu'il faudrait très probablement me livrer à de nombreuses retouches. Bref, disposer d'un éditeur m'est vite apparu essentiel pour travailler confortablement.
J'ai donc produit cela :
L'éditeur de cut scenes de Scoopex THREE
Ce gentil petit éditeur permet de créer facilement une cut scene. A la base, il charge un fichier JSON - oubliez XML pour toujours -, qui contient simplement un tableau repérant les tiles dans le fichier PNG qui les regroupe. Un exemple d'entrée :
{"id":0, "name":"Grass", "type":0, "u":4, "v":0, "nbFrames":1}
Ce tableau permet de mettre à disposition les tiles à déposer à la souris dans chaque frame de la cut scene.
Pour simplifier la vie, l'éditeur est notamment doté des fonctionnalités suivantes :
  • trois layers (le décor, les personnages, les objets) pour permettre de travailler facilement sur des entités d'un certain type sans ruiner ce qui a déjà été produit sur les autres ;
  • copier-coller, effacement et remplissage de l'intégralité d'une frame limité aux layers actifs ;
  • undo infini pour toutes les opérations sur les tiles dans une frame, et aussi pour toutes celles de gestion de l'animation ;
  • chargement et export de la cut scene au format JSON, et export au format DC.B utilisé par le trainer ;
  • lecture de l'animation à partir d'une frame quelconque et retour à cette frame à la fin ou en cas d'interruption.
Ce n'est pas très gros : au plus 2 500 lignes, JavaScript, HTML et CSS - mais de HTML et de CSS, il n'y en a presque pas - confondus. Je mettrai à disposition le code aussitôt que j'aurais produit une première version de l'Ultima-like évoqué.
Pour l'animation, il ne fallait pas chercher midi à quatorze heures : c'est du différentiel. Autrement dit, exception faite de la frame 0, une frame se réduit à la liste des tiles qu'elle remplace dans la frame précédente. Cette technique permet de limiter la taille du fichier décrivant l'animation, mais aussi les opérations graphiques requises pour transformer une frame en la suivante.
Par exemple, dans son format d'exportation DC.B, la frame 1 de la seconde cut scene se résume aux octets suivants :
;Frame 1

DC.B 2,9,14,29,9,15,75
Ce qui se lit ainsi. Il faut modifier 2 tiles :
  • le tile (9, 14) devient le tile d'indice 29 dans la palette des tiles ;
  • le tile (9, 15) devient le tile d'indice 75 dans la palette des tiles.
...ce qui revient à déplacer le personnage d'une case vers le haut, révélant le sentier sur lequel il est censé marcher. Bref, pour passer de la frame 0 à la frame 1, il suffit de redessiner deux tiles, plutôt que de tous les redessiner.
La technique est économique, mais elle présente un inconvénient majeur lors de l'édition. En effet, un tile n'est qu'un tile, c'est-à-dire une instance d'un tile d'une palette de tiles, et non une instance d'une entité comme un personnage, un objet, ou un élément de décor, qui aurait une existence au-delà de la frame. Pour le dire autrement, un tile est à une frame de la cut scene ce qu'un pixel est à une frame d'une animation : il n'est rien de plus qu'une image, il ne comporte aucune information autre que celle qu'il permet de l'afficher.
Cela pose une difficulté particulière pour animer les entités que les tiles représentent, car il n'y a aucun lien entre une instance de tile dans une frame et l'entité qu'il représente - aucune information dans l'instance y renvoie. Par conséquent, il est impossible de dire automatiquement - ie : dans le code - si un tile en (x0, y0) dans la frame N représente la même entité qu'un tile en (x1, y1) dans la frame N+1, voire même en (x0, y0) dans cette frame. Quand bien même c'est un dragon qui est représenté aux deux positions dans les frames, comment affirmer que c'est le même dragon ? Partant, comment dire que l'image du dragon dans la frame N+1 doit être l'image de l'animation d'un dragon qui suit celle utilisée dans la frame N dans cette animation ?
L'animation des tiles ne pouvant être déduite de celle des entités qu'ils représentent, cela fait qu'à chaque frame, il faut modifier à la main le tile représentant l'entité pour produire l'animation de l'entité en question. C'est assez lourd. De plus, si jamais une frame est supprimée ou insérée, il faut modifier à la main les tiles représentant cette entité dans toutes les frames suivantes, à défaut de quoi la continuité de l'animation de l'entité est rompue. Et cela, pour toutes les entités. Autant dire qu'il vaut mieux n'animer les entités entre les frames qu'à la fin, une fois que la liste des frames qui composent la cut scene est définitive.
Il est clair qu'au regard de cette contrainte, créer les cut scenes par animation différentielle sans éditeur aurait été un pur cauchemar.
Sur la manière dont une cut scene est jouée en assembleur, quelques précisions.
Il n'y a pas de double-buffer, pour deux raisons. Tout d'abord, il est pénible de gérer le double-buffer quand il s'agit de jouer du différentiel : à chaque trame, il faut faire progresser la frame que contient le buffer courant de deux frames dans la cut scene et non d'une. Ensuite, ce n'est pas la modification de quelques tiles qui va conduire à ce que le raster rattrape le CPU, produisant le flicker que le double-buffer permet d'éviter, comme je l'ai expliqué ici.
La seule partie un peu délicate du code, et sans doute la plus longue, est la routine _drawText, qui affiche le texte dans une bulle positionnée par rapport à un angle d'un tile désigné dans les données de la frame, sur le modèle suivant :
DC.B 24,9,14,1,"I must find",$0A,"the princess"
Ce qui se lit ainsi : afficher une chaîne de 24 caractères - comprenant éventuellement des retours à la ligne - par rapport à un angle du tile (9, 14). L'angle, et la position de la bulle par rapport à cet angle, est 1. Il y a quatre valeurs possibles :
La routine calcule les dimensions de la bulle, et combine ces informations avec son positionnement relatif pour en déduire les cordonnées où l'afficher.
C'est ici que le code devient un peu subtil, car la bulle n'est pas dessinée dans les bitplanes. Elle est dessinée dans le bitplanes de sprites qui sont accolés pour former un super-sprite. Pourquoi ? Parce que c'est drôle, et parce que cela permet d'éviter d'avoir à gérer du recover.
Pour ne pas s'emmerder outre-mesure, la bulle est tout de même dessinée dans des mini-bitplanes off-screen, et le contenu de ces mini-bitplanes est ensuite recopié, colonne de 16 pixels par colonne de 16 pixels, dans les bitplanes des sprites utilisés. Il aurait été beaucoup trop pénible que le code traçant le cadre, remplissant le fond et écrivant les caractères, fasse de sauts des bitplanes d'un sprite à ceux d'un autre sprite tous les 16 pixels...
Comme expliqué ici, le hardware de l'Amiga 500 peut afficher 8 sprites en quatre couleurs, dont une transparente, d'une largeur de 16 pixels et sur toute la hauteur de l'écran. Ainsi, la largeur maximale d'une bulle est de 16 * 8 = 128 pixels, ce qui fait 15 caractères en police 8x8 par ligne, 4 pixels étant réservés de part et d'autre pour le bord de la bulle. Cela suffit amplement pour les besoins du trainer : les personnages ne déclament pas du Dostoïevski.

La bannière

La bannière est affichée durant la seconde cut scene exclusivement. Y défilent quelques messages qui n'ont rien de subliminal - on ne me fera pas le même procès qu'au générique du journal de 20 heures sur Antenne 2 en son temps !
La bannière, et dump Trump the dumb !
Toujours écrit avec la volonté farouche d'éviter tout recover dans les bitplanes où la cut scene est affichée, le code dessine la bannière dans les bitplanes 5 à 6 où la cut scene n'est pas affichée. Le démarrage de l'animation de la bannière conduit donc à réactiver l'affichage de ces bitplanes...
;Show bitplanes 5 and 6

movea.l copperList,a0
move.w #(DISPLAY_DEPTH<<12)!$0200,10(a0)
...que la fin de l'animation de la bannière conduit à désactiver :
;Hide bitplanes 5 and 6

movea.l copperList,a0
move.w #((DISPLAY_DEPTH-2)<<12)!$0200,10(a0)
Les instructions précédentes tapent dans la Copper list, où elles modifient la valeur qu'une instruction MOVE écrit dans le registre BPLCON0, lequel permet de notamment de contrôler le nombre de bitplanes affichés.
Tout le code de gestion de la bannière a été factorisé pour resservir en d'autres occasions. Ainsi, il adopte un modèle avec lequel ceux qui ont lu le code de Scoopex "TWO" sont désormais familiers, à savoir :
  • une routine de configuration (_bnrSetup) s'appuyant sur une structure de données (bnrSetupData) dont les offsets des différents champs sont identifiés par des constantes (OFFSET_BANNERSETUP_*) ;
  • une routine d'étape (_bnrStep) s'appuyant sur une structure de données (bnrState) dont les offsets des différents champs sont identifiés par des constantes (OFFSET_BANNER_*) ;
  • une routine de réinitialisation (_bnrReset) ;
  • une routine de finalisation (_bnrEnd).
Moins pas souci d'économiser de l'espace en mémoire que de ne pas compliquer le design, la bannière utilise une police de caractères 16x16 qui n'est autre que la police 8x8 du menu et du scroll HiRes dont les dimensions sont doublées. C'est une solution du même genre que celle déjà mise en oeuvre ici, pour produire une police de même taille. D'ailleurs, c'est le même bout de code qui est utilisé pour créer cette police 16x16 à l'initialisation du trainer.

La transition

L'animation cyclique de squares déphasés, c'est un peu une marque de fabrique. J'ai utilisé cet effet dans presque toutes mes cracktros à l'époque, non seulement parce que je trouvais cela élégant, mais aussi et surtout parce que je ne pouvais recourir aux services d'un graphiste. Quand on ne sait pas dessiner, mieux vaut faire simple : c'est toute ma théorie du design.
Le trainer m'a donné l'occasion de recycler cet effet, que j'avais repris en 2016 quand je m'étais remis à programmer en assembleur le hardware de l'Amiga. L'idée était alors de produire un certain nombre d'effets, que j'assemblerai un jour dans une démo. Finalement, ils me servent petit à petit pour produire des productions plus modestes, mais des productions tout de même, comme ce trainer. Les cimetières sont plein de bien meilleurs codeurs qui seront ignorés à jamais, car ils n'ont jamais sorti quelque chose. Et du moins ce code ne sera-t-il ainsi pas perdu pour tout le monde.
L'effet est trivial. L'écran est découpé en squares. Chaque square est animé : en quelques images, ses dimensions se réduisent à presque rien. A chaque trame - ou plus selon la vitesse qu'on souhaite donner à l'effet -, l'animation de chaque square progresse d'une image, rebouclant au début si besoin. L'astuce consiste à déphaser les animations des squares, c'est-à-dire à les faire démarrer toujours à partir de la frame 0 - totalement vide -, mais à des instants différents. Cela permet de produire un motif général, comme ici où l'écran semble se gonfler et se dégonfler :
Une animation cyclique de squares déphasés
Plutôt que d'attendre avant de débuter l'animation d'un square, pourquoi ne pas simplement faire démarrer cette animation avec celle des autres, mais à une frame différente ? Parce que s'il en allait ainsi, la première frame de la transition serait composée de squares contenant des carrés de différentes tailles. Ce serait trop brutal ; mieux vaut qu'au démarrage de son animation, un square soit représenté par un carré de côté minimal.
Dans le trainer, l'effet permet de ménager une transition entre la fin de la seconde cut scene et sa reprise, histoire de marquer la fin :
Une transition à la fin de la seconde cut scene
Les frames de l'animation du square ont été générées à l'aide d'un petit outil HTML5 développé pour l'occasion :
Outil HTML5 de génération des bitmaps de l'animation d'un square
Plus généralement, cet outil permet de générer ce type d'animation sur un certain nombre de bitplanes, et sous la forme de données RAW - les bitplanes d'une frame les uns après les autres -, ou RAWB - les bitplanes d'une frame entrelacés à chaque ligne. Les raisons d'être de ces formats ont été déjà été expliquées ici.
Le motif produit par les animations déphasées des squares a été élaboré dans un outil Excel, ici encore développé pour l'occasion. En associant une couleur à l'indice de la frame de départ de l'animation d'un square, cet outil permet de se faire facilement une idée de ce que sera l'effet produit à l'échelle de l'écran :
Conception des motifs du cutter dans Excel
Les squares de la transition font 16 x 16. Leur motif est inversé, en ce sens où dans une frame de son animation, le square est un carré de bits 0 sur un fond de bits 1. Cela permet de masquer progressivement le décor tandis que l'animation des squares s'achève, la dernière frame de cette animation étant simplement un fond de bits 1. Pour produire ce masquage, les squares sont dessinés dans le bitplane 5. Ainsi, à la fin de la transition, la palette des pixels à l'écran ne s'étend plus des couleurs 00 à 15 issues de la palette des tiles, mais des couleurs 16 à 31, toutes fixées au noir.
La condition d'arrêt de la transition est que tous les squares soient opaques. Comme l'animation de chaque square démarre avec ou sans retard sur celle de la transition, il est difficile de calculer à l'avance la durée totale de la transition, exprimée en trames. Elle dépend de nombreux paramètres. Le fichier cutter.s contient des commentaires qui donnent plus de détails sur l'algorithme et ce sujet.
Tout comme le code de la bannière, celui de la transition a été factorisé pour resservir en d'autres occasions. Il est regroupé dans le fichier cutter.s :
_cutSetup Routine d'initialisation s'appuyant sur une structure de données (cutSetupData) dont les offsets des différents champs sont identifiés par des constantes (OFFSET_CUTTERSETUP_*).
_cutStep Routine d'étape s'appuyant sur une structure de données (cutState) dont les offsets des différents champs sont identifiés par des constantes (OFFSET_CUTTER_*).
_cutReset Routine de réinitialisation.
_cutEnd Routine de finalisation.

Le clavier

La programmation d'un pilote du clavier sur Amiga n'est pas la chose la plus aisée. J'ai déjà exposé ici dans le détail comment s'y prendre pour simplement récupérer le code d'une touche pressée puis relâchée, non seulement par interruption, mais aussi par polling. Dans ce trainer, c'est la seconde solution qui est retenue.
Le pilote gère les pressions suivies de relâchements des touches suivantes :
  • durant le menu, la touche Espace fait basculer sur la seconde cut scene ;
  • durant le menu, une touche correspondant à un chiffre - la rangée de touches au-dessus des lettres, pas celles du pavé numérique - fait jouer un morceau donné du module ;
  • durant la seconde cut scene, la touche Espace fait basculer sur le menu sauf durant l'affichage de la bannière et la transition.
Ce qu'il convient de plus de préciser, c'est que le pilote du clavier n'est pas toujours actif. En particulier, presser puis relâcher la touche Espace lorsque lors de l'animation de la bannière durant la seconde cut scene, ou lors de la transition à la fin de cette dernière, ne produit rien. Pourtant, le joueur pourrait s'attendre à être renvoyé vers le menu, comme c'est le cas lorsqu'il presse puis relâche cette touche à n'importe quel autre moment durant cette cut scene. Alors, pourquoi couper le clavier ?
La raison, c'est que cela simplifie la gestion de la sortie de ces deux états de la cut scene. Plutôt que d'avoir à gérer un cas où il faut en sortir alors que ni la bannière ni la transition n'est en cours, un cas où la bannière est en cours, et un cas où la transition est en cours, il suffit de gérer le premier cas. C'est que sortir de la bannière ou de la transition implique une série d'opérations pour restaurer les bitplanes utilisés du menu en l'état. Mieux vaut s'épargner cette complication au prix d'une neutralisation du clavier que le joueur ne remarquera sans doute jamais, la bannière et la transition ne durant que peu de temps.
Or il ne suffit pas de ne plus appeler le pilote pour couper le clavier. En effet, chaque fois que le joueur presse et relâche une touche, le code de cette dernière s'accumule dans un buffer du hardware. Dès lors, lorsque le pilote est réactivé, il se met à gérer chaque touche dont le code se trouve dans le buffer. Cela peut produire cet effet particulièrement indésirable de prise en compte à retard de la pression suivie du relâchement d'une touche.
Il faut donc vider ce buffer avant de réactiver le pilote. C'est le rôle de ce petit bout de code, factorisé dans une macro pour être répété facilement aux divers endroits où il s'avère nécessaire, c'est-à-dire durant l'animation de la bannière et la transition. En l'espèce :
EMPTY_KEYBOARD:	MACRO
_keyboardFlush\@:
	btst #3,$BFED01
	beq _keyboardEmpty\@
	bset #6,$BFEE01
	WAIT_RASTER 2
	bclr #6,$BFEE01
	WAIT_RASTER 16		;Time required by CIA to move 8 bits from 10 keys keyboard buffer to SDR and set the interrupt bit in ICR
	bra _keyboardFlush\@
_keyboardEmpty\@:
	ENDM	

La musique

Suite à la publication d'une petite annonce sur Wanted pour trouver des comparses afin de produire ma série d'hommages, l'excellent JMD m'a contacté pour me proposer ses services de musicien.
JMD est particulièrement versé dans l'art du chip tunes, comme chacun pourra le constater en écoutant quelques-unes de ses productions sur bandcamp, ou plus exhaustivement AMP. D'ailleurs, quelle ne fut pas ma surprise de constater que dans la liste des jeux auxquels il a participé, on trouve Despot Dungeon, qui a toute l'apparence d'un Ultima-like !
Grâce soit rendue à JMD, car je dois dire qu'il m'a composé un module aux petits oignons. Qu'on y songe : ce dernier comporte presque une dizaine de mélodies sur mesure, d'après les thèmes que je lui avais indiqués. Du travail de pro !
D'ailleurs, constatant la qualité du travail fourni, j'ai tout de suite regretté que le joueur ne puisse finalement qu'en écouter de brefs passages en regardant la cut scene. Comme le pilote du clavier était tout programmé, ne convenait-il pas de rajouter la possibilité de presser les touches numériques - pas celles pavé, mais celles qui figurent au-dessus des caractères - pour écouter à volonté chacune des mélodies ?
Aussitôt dit, aussitôt fait ! Il suffisait de rajouter quelques lignes. Le joueur est donc invité à presser les touches 1 à 8 pour écouter les divers morceaux dans leur intégralité.
J'en profite pour saluer phx, toujours actif sur Amiga, pour la replay routine de modules ProTracker - retouchée à la marge par StingRay. On oublie toujours de le remercier, alors que sa routine doit être aussi utilisée dans les productions de la scène, que Forbid (). Frank Wille - car c'est lui - a donné cette interview en 2016. Elle permet de constater l'ampleur de sa contribution.

Le scroller

J'allais oublier le scroller ! Il serait dommage de ne pas l'évoquer, car il permet d'illustrer comment il est possible d'utiliser le Copper pour modifier la résolution de l'écran à n'importe quelle ligne de ce dernier. En effet, le scroller est en HiRes (640x256) alors que tout le reste est en LowRes (320x256).
J'avais déjà programmé un scroller en HiRes autrefois. En fait, il en apparaît un en bas de la première - si je me souviens bien - cracktro que j'ai produite :
Scroll HiRes dans la cracktro de Flashback
Et dans cette autre cracktro, j'avais utilisé la possibilité de modifier la résolution de l'écran à partir d'une certaine ligne - en passant de plus en entrelacé :
Changement de résolution dans la cracktro d'Ishar II
Dans le trainer, le scroller est assez optimal, pour deux raisons :
  • il s'appuie sur le scrolling hardware ;
  • à chaque étape, un caractère est recopié, un autre est écrit.
Le scrolling est hardware. A la hauteur du scroller, le contenu du fameux registre BPLCON1 - celui-là même qui a été détourné pour réaliser du zoom hardware - est modifié pour décaler l'image sur la gauche. Toutefois, en HiRes, le hardware n'interprète pas les valeurs des bits PF2H3-0 et PF1H3-0 comme en LowRes. En HiRes, les valeurs possibles pour PFxH3-0 ne vont pas de 0 à 15, mais de 0 à 7, et chaque incrément de 1 décale les bitplanes concernés de 2 pixels HiRes (1 pixel LowRes).
Les tableaux suivants permettent de s'y retrouver. Pour une amplitude de scrolling donné, ils donnent la valeur des bits PFxH3-0 à écrire dans BPLCON1 et l'offset à écrire dans BPLxPTH/L, et de là la formule qui permet de trouver les valeurs des PFxH3-0 à partir de celle de l'amplitude :
LowRes
AmplitudeBPLCON1BPLxPTH/L
0$0000+0
1$00FF+2
2$00EE+2
...
14$0022+2
15$0011+2
16$0000+2
17$00FF+4
...
31$0011+4
32$0000+4
33$00FF+6
...
HiRes
AmplitudeBPLCON1BPLxPTH/L
0$0000+0
1$0000+0
2$0077+2
3$0077+2
4$0066+2
5$0066+2
...
12$0022+2
13$0022+2
14$0011+2
15$0011+2
16$0000+2
17$0000+2
18$0077+4
19$0077+4
...
30$0011+4
31$0011+4
32$0000+4
33$0000+4
34$0077+6
35$0077+6
...
De l'analyse des tableaux précédentes, il ressort que :
  • en LowRes, la valeur de PFxH3-0 est (~(amplitude-1))&$F :
  • en HiRes, la valeur de PFxH3-0 est (~((amplitude>>1)-1))&$7.
A chaque étape, un caractère est recopié, un autre est écrit. Si le scroll est une bande de l'écran qui défile sur la gauche, cette bande a nécessairement une fin. Cela implique qu'à un moment donné, il va falloir reboucler sur le début de cette bande. Comme cela va entraîner l'affichage de caractères qui ont depuis longtemps défilés sur la gauche, il convient donc de recopier la fin de la bande à son début avant de reprendre le scroll à cé début.
La technique qui vient spontanément à l'esprit consiste à utiliser une bande dont la largeur est simplement accrue d'un caractère sur la droite. Dans cet exemple, l'écran fait trois caractères de large :
Or cette technique présente un inconvénient. Quand le moment est venu d'écrire un nouveau caractère à la fin de la bande, il faut d'abord recopier les trois caractères finaux au début de la bande. Bilan : quatre caractères manipulés.
Une meilleure solution consiste à utiliser une bande dont la largeur est accrue d'autant de caractères que la largeur de l'écran peut en afficher. Pour reprendre l'exemple précédent, la bande fait donc six caractères de large :
Ici, quand le moment est venu d'écrire un nouveau caractère, le dernier caractère écrit - qui est alors pleinement affiché à droite de l'écran - est d'abord recopié à gauche de l'écran. Bilan : deux caractères manipulés, seulement.
La combinaison du scroll hardware et de la bande de double largeur fait que dans le code du trainer, le bloc de mémoire utilisé pour afficher le scroll, scrollFrontBuffer, a une largeur de ((2*SCROLL_DX+16)>>3) octets, SCROLL_DX valant 640 pixels. La raison pour laquelle le scroll hardware nécessite 16 pixels supplémentaire ne sera pas détaillée ici. C'est fort bien expliqué , dans l'Amiga Hardware Reference Manual.
Tout comme le code de la bannière et celui de la transition, celui de la transition a été factorisé pour resservir en d'autres occasions. Il est regroupé dans le fichier scroll.s :
_sclSetup Routine d'initialisation s'appuyant sur une structure de données (sclSetupData) dont les offsets des différents champs sont identifiés par des constantes (OFFSET_SCROLLSETUP_*).
_sclStep Routine d'étape s'appuyant sur une structure de données (sclState) dont les offsets des différents champs sont identifiés par des constantes (OFFSET_SCROLL_*).
_sclEnd Routine de finalisation.
Par exception, je constate rétrospectivement qu'il n'y a pas de routine _sclReset. J'ai dû avoir la flegme de terminer...

La boucle principale

A ce stade, il ne reste plus qu'à évoquer la boucle principale, tout particulièrement la manière dont les différentes parties du trainer s'enchaînent.
La boucle est simplement une série de tests portant sur des flags conservés dans un WORD à l'adresse mainState, qui reflète l'état dans lequel se trouve l'automate du programme à l'instant présent :
STATE_CUTSCENE_RUNNING=$0001
STATE_CUTSCENE_ENDING=$0002
STATE_BANNER_RUNNING=$0004
STATE_MENU_RUNNING=$0010
STATE_KEYBOARD_RUNNING=$0020
Lorsqu'une action doit provoquer un changer d'état de l'automate, elle efface et/ou positionne certains flags. Par exemple, lorsque le menu est affiché - ce qui est l'état de démarrage -, les flags STATE_CUTSCENE_RUNNING, STATE_KEYBOARD_RUNNING, STATE_MENU_RUNNING.
Lors d'une itération de la boucle principale, chaque flag est testé et donne lieu à l'exécution d'un bout code associé. Ainsi, dans le cas précédent :
  • STATE_CUTSCENE_RUNNING entraîne l'animation de la cut scene dont l'adresse est stockée en cutScene ;
  • STATE_KEYBOARD_RUNNING entraîne le test du clavier en l'exécution de l'action associée à l'éventuel relâchement d'une touche ;
  • STATE_MENU_RUNNING entraîne le test de la souris et l'exécution de l'action associée à un éventuel mouvement vertical ou au relâchement d'un bouton.
Certains flags seront effacés quand l'automate rentre dans un certain état. Par exemple, si l'utilisateur presse la touche Espace dans l'état précédent - menu du trainer affiché sur la première cut scene -, l'automate bascule dans l'état où c'est la seconde cut scene qui est jouée, sans qu'aucun menu ne soit affiché. Il ne faut alors plus gérer le menu, si bien que le flag STATE_MENU_RUNNING n'est plus positionné dans menuState.
Somme toute, la structure de la boucle principale n'est qu'une imbrication de switch. Une autre solution aurait été de créer une table de pointeurs de routines, et de limiter la boucle principale à des manipulations sur cette table, tout le code étant autrement factorisé dans les routines en question. Toutefois, sachant qu'il n'y avait somme toute que peu de code, j'ai préféré ne pas pousser jusque-là.

Et voilà!

Non compressé, le trainer pèse 91 852 Ko. C'est énorme pour un trainer, Fort heureusement, un problème de ce type peut être géré sur Amiga grâce à un cruncher, c'est-à-dire un programme qui peut en compresser un autre, produisant un exécutable contenant tout ce qu'il faut pour décompresser cette version compressée une fois qu'elle a été chargée en mémoire.
En l'espèce, c'est l'excellent Crunch-Mania qui a été retenu, étant aussi élégant qu'efficace. Ce cruncher s'est plié en quatre, ou plutôt il est parvenu à plier en quatre le trainer, produisant un exécutable de seulement... 26 416 Ko ! Sans doute, cela reste très conséquent pour un trainer, mais cela permet de l'utiliser pour de nombreux jeux qui n'occupent pas l'intégralité des 880 Ko d'une disquette.
Crunch-Mania pour pliant le trainer en quatre
Et comme d'habitude une aventure d'Astérix le gaulois se termine toujours autour d'un bon banquet, une production de la sorte doit inévitablement se terminer par les greetings. Je remercie donc tout particulièrement :
  • JMD pour son excellente musique ;
  • StingRay pour l'opportunité de produire ce trainer.
A bientôt pour la prochaine ? Ce devrait être un hommage aux sysops sous la forme d'une BBS-intro pour le groupe Desire. Je dois bien cela à Ramon B5 pour m'avoir sauvé la mise sur Scoopex "TWO".

Et le tileset ?

Pour recréer le tileset, par exemple à partir de celui d'Ultima V que vous trouverez ici, commencez par produire un PNG de 4 x 21 tiles. Composez alors votre tileset selon ce modèle :
Child (1/4)Child (2/4)Child (3/4)Child (4/4)
Rogue (1/4)Rogue (2/4)Rogue (3/4)Rogue (4/4)
Warrior (1/4)Warrior (2/4)Warrior (3/4)Warrior (4/4)
Beggar (1/4)Beggar (2/4)Beggar (3/4)Beggar (4/4)
Troll (1/4)Troll (2/4)Troll (3/4)Troll (4/4)
Avatar (1/4)Avatar (2/4)Avatar (3/4)Avatar (4/4)
Soldier (1/4)Soldier (2/4)Soldier (3/4)Soldier (4/4)
Guy (1/4)Guy (2/4)Guy (3/4)Guy (4/4)
Daemon (1/4)Daemon (2/4)Daemon (3/4)Daemon (4/4)
Ghost (1/4)Ghost (2/4)Ghost (3/4)Ghost (4/4)
Blackthorn (1/4)Blackthorn (2/4)Blackthorn (3/4)Blackthorn (4/4)
Lord British (1/4)Lord British (2/4)Lord British (3/4)Lord British (4/4)
Shadowlord (1/4)Shadowlord (2/4)Shadowlord (3/4)Shadowlord (4/4)
Dragon (1/4)Dragon (2/4)Dragon (3/4)Dragon (4/4)
Orc (1/4)Orc (2/4)Orc (3/4)Orc (4/4)
PotionParcheminEpéeBouclier
BassinetArmureExplosionSang
HerbeBuissonForêt (petite)Forêt (moyenne)
Forêt (grande)ArbreCaillouxSentier (N-S)
Sentier (E-O)Sentier (N-E)Sentier (S-O)Sentier (S-E)
Sentier (N-O)TombeSentier (NSEO)Arbre mort
Vous pouvez vous référer au The Codex of Ultima Wisdom pour vous y retrouver dans les créatures.
Chargez ensuite l'outil BOBsConverter.html qui se trouve dans le répertoire tools. Attention ! veillez bien à placer votre PNG dans le répertoire avant d'utiliser l'outil, car les contraintes de sécurité du navigateur vous interdiront de charger un fichier PNG depuis un autre endroit que ce répertoire.
Dans l'outil, chargez votre fichier PNG. Ce dernier doit s'afficher dans Input. Sélectionnez le format RAWB, puis cliquez sur Convert!. Le résultat de la conversion doit s'afficher dans Output. Copiez-collez le code qui apparaît dans la fenêtre Data dans un fichier. Chargez ce fichier dans ASM-One, assemblez-le avec la commande A, puis enregistrez le binaire qui en résulte dans un fichier tiles.rawb avec la commande WB en spécifiant start et end comme libellés de départ et de fin.
Scoopex « THREE » : Le coding-of d’un menu de trainer sur Amiga