Dots tunnel : des tables au premier tunnel en Z80
Le premier article s’est arrêté sur deux tables d’octets, SHAPE et CENTER, calculées en JavaScript. Grâce à elles, la boucle du tunnel trouve la position (x, y) de chaque point par de simples lectures en mémoire et quelques additions. Mais pour dessiner le point, elle s’en remet encore à g.rect(x, y, 1, 1), une fonction de PixiJS.
Sur le CPC, le programme doit écrire lui-même chaque point dans la mémoire vidéo. Cet article va jusqu’au tunnel sur l’écran du CPC, en trois temps. Le CPC calcule d’abord les tables en Locomotive BASIC (le BASIC intégré à la machine) et les sauve dans un fichier. RASM calcule ensuite les mêmes tables au moment d’assembler, et doit retrouver ce fichier octet pour octet. Une première version en Z80 charge enfin le fichier et dessine le tunnel, sans chercher la vitesse, et on mesure où passe son temps. Mais le rangement des pixels du CPC demande une table de plus, et c’est par lui que tout commence.
La mémoire écran du CPC
Le CPC n’a pas de mémoire vidéo à part : l’écran occupe 16 Ko de la mémoire ordinaire, de &C000 à &FFFF (en BASIC, & annonce un nombre en hexadécimal). Pour allumer un pixel, le programme écrit dans cette zone comme dans n’importe quelle autre case de la mémoire.
Un octet, quatre pixels
Le tunnel tourne en mode 1 : 320 × 200 pixels et quatre encres. Choisir une encre parmi quatre demande 2 bits, donc un octet de 8 bits contient 4 pixels.
Et ces bits ne se suivent pas. Le pixel de gauche prend les bits 7 et 3, le suivant les bits 6 et 2, puis viennent 5 et 1, et enfin 4 et 0 :
| Bit de l’octet | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Pixel | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 |
Pour dessiner le pixel x, il faut donc trouver l’octet qui le contient, puis les deux bits du pixel dans cet octet.
Une ligne, 80 octets
Une ligne de 320 pixels occupe 320 / 4 = 80 octets. Le pixel x est dans l’octet x \ 4 de sa ligne (\ est la division entière du BASIC). Sa place dans l’octet est le reste de cette division, x AND 3 : 0 pour le pixel de gauche, 3 pour celui de droite.
Des lignes entrelacées
On s’attendrait à trouver la ligne 1 de l’écran juste après la ligne 0, 80 octets plus loin, mais le CPC range ses lignes autrement. Son écran est fait de 25 rangées de caractères, de 8 lignes chacune. Les lignes 0 des 25 rangées se suivent en mémoire, les lignes 1 commencent 2 048 octets plus loin (&800), et ainsi de suite jusqu’aux lignes 7.
Pour la ligne y, la rangée est y \ 8 et la ligne dans la rangée est y AND 7. La ligne y commence donc à l’adresse &C000 + (y\8)*80 + (y AND 7)*2048.
Un pixel
En réunissant ces trois règles, le pixel (x, y) est dans l’octet à l’adresse :
&C000 + (y\8)*80 + (y AND 7)*2048 + x\4
Par exemple, le pixel (100, 50) est dans la rangée 6, à la ligne 2 de cette rangée, et dans l’octet 25 de sa ligne. Son adresse est &C000 + 480 + 4096 + 25, soit &D1F9. Et comme 100 AND 3 vaut 0, c’est le pixel de gauche de cet octet : il prend les bits 7 et 3.
Pourquoi une table pour les lignes
Le Z80 doit faire ce calcul pour chaque point, et le tunnel en compte 256 par image (16 anneaux de 16 points).
La partie en x ne lui coûte presque rien. Diviser par 4 revient à décaler les bits de deux rangs vers la droite, et x AND 3 garde les deux bits du bas, qui donnent la place du pixel dans l’octet.
La partie en y est une autre affaire. Le Z80 sait additionner et décaler des bits, mais il n’a pas d’instruction pour multiplier : le *80 devient une suite de décalages et d’additions, sur des nombres de 2 octets.
On reprend donc la recette du premier article : calculer une fois pour toutes, à l’avance, et ranger les résultats dans une table. Pour chaque ligne y, la table donne (y\8)*80 + (y AND 7)*2048. Ce nombre va jusqu’à 16 Ko et ne tient pas dans un octet, alors qu’une table ne range qu’un octet par case. Il se coupe donc en deux, l’octet bas dans YLO et l’octet haut dans YHI.
Le début de l’écran, &C000, reste hors de la table, et le Z80 l’ajoutera au moment de dessiner : la même table servira ainsi pour un écran placé ailleurs en mémoire.
Une table pourrait aussi remplacer le calcul en x. On la garde pour plus tard : sans mesure, rien ne dit qu’elle vaut la peine.
Des tables alignées sur 256
Une adresse du CPC tient sur 2 octets, un octet haut et un octet bas. L’adresse &4023 a l’octet haut &40 et l’octet bas &23.
Prenons YLO à l’adresse &4000. La case y est à l’adresse &4000 + y, et comme y reste sous 256, cette addition ne touche que l’octet bas : &4000 + &23 donne &4023. Le Z80 n’a donc rien à additionner. Il met &40 dans l’octet haut, y dans l’octet bas, et lit la case.
Si YLO commençait à &4080, la case &90 tomberait à &4110, et l’octet haut changerait. Le Z80 devrait alors faire une vraie addition sur 2 octets, pour chaque point.
Voilà pourquoi chaque table commence à une adresse multiple de 256. La règle a un prix (une table indexée par y a 256 cases au plus), mais il ne gêne pas ici : l’écran a 200 lignes, et les points du tunnel restent entre y = 10 et y = 190. On a vérifié ces bornes sur toutes les cases de SHAPE et de CENTER.
CENTER et SHAPE sont plus grandes, mais commencent elles aussi à une adresse multiple de 256. Les quatre tables occupent 10,5 Ko à partir de l’adresse &4000 :
| Table | Adresse | Taille | Contenu |
|---|---|---|---|
YLO |
&4000 |
256 | l’adresse d’une ligne, octet bas |
YHI |
&4100 |
256 | l’adresse d’une ligne, octet haut |
CENTER |
&4200 |
2 048 | les centres des anneaux |
SHAPE |
&4A00 |
8 192 | les formes des anneaux |
Les tables en Locomotive BASIC
Le programme ne dessine rien. Il remplit la mémoire, octet par octet, avec POKE, puis il sauve le résultat.
Le BASIC range lui aussi ses variables en mémoire. MEMORY &3FFF lui interdit de dépasser l’adresse &3FFF, et la zone des tables reste au programme. Les lignes 30 et 40 donnent les constantes du JavaScript et l’adresse de chaque table :
10 MODE 1:MEMORY &3FFF
20 t=TIME
30 CX=160:CY=100:P=90:R=1:Z0=1:DOTS=16:AX=72:AY=50
40 YLO=&4000:YHI=&4100:CENTER=&4200:SHAPE=&4A00
TIME compte le temps en 300ᵉ de seconde. La ligne 20 note l’heure du départ, pour afficher la durée à la fin.
Les noms sont ceux du premier article, et pour CENTER et SHAPE, le listing se lit à côté du dernier playground, boucle pour boucle. Seule différence : en JavaScript, SHAPE et CENTER sont des tableaux, alors qu’ici ce sont des adresses, et POKE SHAPE+n tient la place de SHAPE[n].
YLO et YHI
La ligne 60 calcule l’adresse de la ligne y, sans le début de l’écran. La ligne 70 la coupe en deux : v AND 255 garde l’octet bas, v \ 256 donne l’octet haut. Les lignes 200 à 255 n’existent pas, et leurs cases restent à 0 :
50 PRINT "YLO, YHI"
60 FOR y=0 TO 255:v=0:IF y<200 THEN v=(y\8)*80+(y AND 7)*2048
70 POKE YLO+y,v AND 255:POKE YHI+y,v\256:NEXT
CENTER
CENTER reprend la table du premier article, ligne pour ligne : 16 niveaux q, 64 phases phase, deux octets par case :
80 PRINT "CENTER"
90 FOR q=0 TO 15:FOR phase=0 TO 63:n=(q*64+phase)*2
100 POKE CENTER+n,CX+INT(AX*q/15*SIN(2*PI*phase/64)+0.5)
110 POKE CENTER+n+1,CY+INT(AY*q/15*SIN(4*PI*phase/64+0.7)+0.5)
120 NEXT:PRINT ".";:NEXT:PRINT
Le PRINT "."; de la ligne 120 affiche un point à chaque niveau : sans lui, rien ne bouge à l’écran pendant tout le calcul.
Arrondir
Le JavaScript arrondit avec Math.round. Le BASIC a bien ROUND, mais les deux ne s’accordent pas quand un nombre tombe juste au milieu de deux entiers : Math.round(2.5) donne 3 et ROUND(2.5) donne 2, Math.round(-2.5) donne −2 et ROUND(-2.5) donne −3.
INT, lui, garde l’entier juste en dessous : INT(2.7) donne 2, et INT(-2.3) donne −3. Ajouter 0,5 avant INT arrondit donc comme Math.round, milieux compris, et c’est ce que font les lignes 100 et 110.
SHAPE
SHAPE est la plus grosse table : 64 profondeurs d, 4 rotations rot, 16 points k et deux octets par point, soit 4 096 cosinus et autant de sinus. Le calcul suit celui du JavaScript, à deux détails près, que la section suivante explique : le rayon s à la ligne 140, et le sinus à la ligne 170.
130 PRINT "SHAPE"
140 FOR d=0 TO 63:s=P*R/(Z0+d*3/20)
150 FOR rot=0 TO 3:FOR k=0 TO DOTS-1:angle=2*PI*(4*k+rot)/64:n=((d*4+rot)*DOTS+k)*2
160 POKE SHAPE+n,INT(s*COS(angle)+0.5) AND 255
170 POKE SHAPE+n+1,INT(s*COS(angle-PI/2)+0.5) AND 255
180 NEXT:NEXT:PRINT ".";:NEXT:PRINT
Des octets négatifs
Chaque case de SHAPE range un écart au centre de l’anneau, dx ou dy, de −90 à 90. Un point à gauche du centre a un dx négatif, un point au-dessus un dy négatif. Or POKE n’écrit qu’un octet, de 0 à 255 : il faut donc coder un nombre négatif dans un octet.
Le Z80 suit la même règle que l’Int8Array du JavaScript, le complément à deux : un octet range −n sous la forme 256 − n. −1 devient 255, −22 devient 234, et −90 devient 166. Les nombres de 0 à 127 gardent leur valeur, et les octets de 128 à 255 codent les nombres de −128 à −1.
Le BASIC fait ce calcul tout seul. Il range ses entiers sur 2 octets, avec la même règle : −22 y devient 65 536 − 22, soit &FFEA. AND 255 garde l’octet bas, &EA, c’est-à-dire 234, et c’est ce que font les lignes 160 et 170.
Deux pièges du flottant
Pour vérifier le programme, on a comparé chaque octet aux tables du premier article, calculées en JavaScript. Recopié tel quel, le calcul de SHAPE donnait 2 octets faux sur 8 192, tous deux à la profondeur d = 20. À cette profondeur, le rayon s vaut exactement 22,5, et un point à 22,5 pixels du centre tombe juste au milieu de deux pixels : le moindre écart de calcul le fait basculer d’un côté ou de l’autre.
Le premier piège tient à la profondeur. Le JavaScript l’écrit Z0 + d * DZ, avec DZ = 0.15, mais le CPC range ses nombres en binaire, et 0,15 ne s’écrit pas exactement en binaire (pas plus que 1/3 en décimal). Pour d = 20, le rayon 90/(1+20*0.15) vaut alors 22,4999999925 au lieu de 22,5, et l’arrondi donne 22 au lieu de 23. La ligne 140 écrit donc DZ sous la forme 3/20, et dans d*3/20, le BASIC multiplie avant de diviser : pour d = 20, 60/20 donne 3 tout juste, et le rayon retombe sur 22,5.
Le second piège vient du sinus. Sur le CPC, SIN(PI/2) ne vaut pas tout à fait 1 (il lui manque un peu plus de 2 dix-milliardièmes), alors que COS(0) vaut exactement 1. Or un sinus n’est qu’un cosinus décalé d’un quart de tour : la ligne 170 calcule COS(angle-PI/2) à la place de SIN(angle). Au quart de tour, le cosinus porte sur 0 et donne un vrai 1. SIN en degrés, après DEG, se trompe de la même façon.
En JavaScript, les deux calculs tombent juste : 1 + 20 * 0.15 y donne bien 4, et Math.sin(Math.PI / 2) donne 1, car il calcule avec plus de chiffres. Et ces écarts ne touchent que les points qui tombent pile au milieu de deux pixels. Ailleurs, le point le plus proche du milieu en est encore à 6 cent-millièmes de pixel, bien au-delà de l’erreur du CPC.
Sauver les tables
Il reste à garder les tables. Personne ne veut attendre 5 minutes à chaque lancement du tunnel : le programme sauve donc les 10 752 octets dans un fichier binaire, TABLES.BIN. Le B de SAVE demande un fichier binaire, suivi de l’adresse de départ et de la longueur, &2A00, soit 256 + 256 + 2 048 + 8 192 octets :
190 PRINT "DUREE :";ROUND((TIME-t)/300,1);"S"
200 SAVE "tables",B,&4000,&2A00
AMSDOS ajoute devant les octets un header de 128 octets, avec le nom, l’adresse et la longueur du fichier. Le fichier occupe donc 10 880 octets sur la disquette.
Le programme complet
10 MODE 1:MEMORY &3FFF
20 t=TIME
30 CX=160:CY=100:P=90:R=1:Z0=1:DOTS=16:AX=72:AY=50
40 YLO=&4000:YHI=&4100:CENTER=&4200:SHAPE=&4A00
50 PRINT "YLO, YHI"
60 FOR y=0 TO 255:v=0:IF y<200 THEN v=(y\8)*80+(y AND 7)*2048
70 POKE YLO+y,v AND 255:POKE YHI+y,v\256:NEXT
80 PRINT "CENTER"
90 FOR q=0 TO 15:FOR phase=0 TO 63:n=(q*64+phase)*2
100 POKE CENTER+n,CX+INT(AX*q/15*SIN(2*PI*phase/64)+0.5)
110 POKE CENTER+n+1,CY+INT(AY*q/15*SIN(4*PI*phase/64+0.7)+0.5)
120 NEXT:PRINT ".";:NEXT:PRINT
130 PRINT "SHAPE"
140 FOR d=0 TO 63:s=P*R/(Z0+d*3/20)
150 FOR rot=0 TO 3:FOR k=0 TO DOTS-1:angle=2*PI*(4*k+rot)/64:n=((d*4+rot)*DOTS+k)*2
160 POKE SHAPE+n,INT(s*COS(angle)+0.5) AND 255
170 POKE SHAPE+n+1,INT(s*COS(angle-PI/2)+0.5) AND 255
180 NEXT:NEXT:PRINT ".";:NEXT:PRINT
190 PRINT "DUREE :";ROUND((TIME-t)/300,1);"S"
200 SAVE "tables",B,&4000,&2A00
La disquette tables.dsk contient ce programme. Dans un émulateur ou sur un vrai CPC, RUN"TABLES le lance, et le CPC met un peu moins de 5 minutes à remplir les tables (284 secondes sur un 6128 émulé). La disquette doit accepter l’écriture, sinon le SAVE de la fin échoue.
Pour vérifier les octets, cette ligne fait la somme de toute la zone des tables. Chaque octet y compte avec un poids qui dépend de son adresse, pour qu’un octet mal placé change lui aussi le résultat :
c=0:FOR i=&4000 TO &69FF:c=c+PEEK(i)*((i AND 15)+1):NEXT:PRINT c
Elle doit afficher 11232147, la somme des tables calculées en JavaScript par le premier article, plus la table des lignes.
Les mêmes tables avec RASM
Le CPC sait donc calculer ses propres tables, mais il y passe près de 5 minutes, et il faudrait recommencer à chaque constante modifiée. RASM, lui, sait calculer pendant l’assemblage : son langage a des variables, des boucles, des conditions et les fonctions mathématiques usuelles. Il peut donc écrire les tables lui-même, au moment d’assembler, dans un fichier. RASM n’a rien d’unique sur ce point : tout assembleur correct propose des moyens semblables, sous d’autres noms (sjasmplus, par exemple, a DUP pour répéter un bloc).
Le fichier tables-fr.asm commence par les constantes du JavaScript, puis place les tables à partir de #4000 (RASM écrit l’hexadécimal avec #, là où le BASIC met &) :
CX = 160
CY = 100
P = 90
R = 1
Z0 = 1
DZ = 0.15
DOTS = 16
AX = 72
AY = 50
org #4000
; sin() et cos() de RASM prennent des degrés : un tour vaut 360, pas 2*PI.
; floor(x+0.5) arrondit comme le INT(x+0.5) du BASIC.
repeat 256,y,0 répète le bloc jusqu’au rend 256 fois, avec y de 0 à 255, et chaque db y pose un octet. Les opérateurs viennent du C : >> décale vers la droite et & fait un ET bit à bit. (y>>3)*80 est donc le (y\8)*80 du BASIC :
YLO: repeat 256,y,0
if y<200
db ((y>>3)*80+(y&7)*2048)&255
else
db 0
endif
rend
YHI: repeat 256,y,0
if y<200
db ((y>>3)*80+(y&7)*2048)>>8
else
db 0
endif
rend
CENTER et SHAPE suivent le BASIC boucle pour boucle :
CENTER: repeat 16,q,0
repeat 64,phase,0
db CX+floor(AX*q/15*sin(360*phase/64)+0.5)
db CY+floor(AY*q/15*sin(720*phase/64+0.7*180/pi)+0.5)
rend
rend
SHAPE: repeat 64,d,0
s = P*R/(Z0+d*DZ)
repeat 4,rot,0
repeat DOTS,k,0
angle = 360*(4*k+rot)/64
db floor(s*cos(angle)+0.5)&255
db floor(s*sin(angle)+0.5)&255
rend
rend
rend
Deux détails changent. Les fonctions sin et cos de RASM prennent des degrés : un tour vaut 360, et le 0,7 radian de CENTER devient 0.7*180/pi. Et floor, qui garde l’entier juste en dessous, tient la place d’INT.
Les deux détours du BASIC, eux, ne servent plus. RASM reprend Z0+d*DZ avec DZ = 0.15 et sin(angle), comme le JavaScript, et tombe juste aux 4 points de d = 20, car il calcule avec plus de chiffres que le BASIC.
Il reste à sauver le résultat. $ est l’adresse courante, et $-YLO la longueur des tables, #2A00 :
save "tables-rasm.bin",YLO,$-YLO
RASM assemble tables-fr.asm et écrit tables-rasm.bin en un centième de seconde, contre 284 secondes pour le BASIC. Le fichier du BASIC a 128 octets de plus, le header AMSDOS. Sans ce header, les deux fichiers sont identiques, octet pour octet.
RASM fera les tables des versions qui changeront la forme du tunnel : on change une constante, on assemble, et le tunnel suit. Le BASIC garde son rôle de référence, qu’on peut lancer sur la machine elle-même. Tant que la forme ne change pas, les versions du tunnel chargent son fichier, TABLES.BIN.
Une première version en Z80
La v1 reprend la boucle du dernier playground du premier article, dans la version la plus naïve possible. Elle tourne sans le firmware du CPC, dans un snapshot : un fichier .sna, qui contient l’image de la mémoire et des registres de la machine. L’émulateur le charge et le lance d’un coup, et RASM sait l’écrire lui-même.
Le départ
buildsna demande un snapshot à RASM. Les constantes reprennent la carte mémoire du BASIC, l’adresse de l’écran et les noms RINGS et DOTS du JavaScript :
buildsna
bankset 0
YLO = #4000 ; la carte mémoire de tables.bas
YHI = #4100
CENTER = #4200
SHAPE = #4A00
SCREEN = #C000 ; l'écran que le snapshot affiche
RINGS = 16
DOTS = 16
incbin copie le fichier du BASIC dans la mémoire du snapshot, à partir de YLO. Le 128 saute le header AMSDOS. run start donne l’adresse où le programme démarre :
org YLO
incbin "TABLES.BIN",128 ; sans le header AMSDOS de 128 octets
org #1000
run start
start: di
ld sp,#1000
call init
Le snapshot ne contient que ce programme : le firmware n’a rien installé en mémoire. di coupe donc les interruptions, que personne ne traiterait. La pile part de #1000 et descend, sous le code.
Préparer la machine
init parle au Gate Array, le circuit qui fabrique l’image, par le port #7Fxx. #8D choisit le mode 1 et coupe les deux ROM. Ensuite, chaque encre reçoit sa couleur, en deux out : le numéro de l’encre, puis sa couleur. #10 désigne la bordure :
; init : mode 1, ROM coupées, palette.
init: ld bc,#7F8D
out (c),c
ld hl,palette
ld e,0
next_ink:
out (c),e ; E < #10 : choisit l'encre E
ld a,(hl)
out (c),a ; #40 à #5F : lui donne sa couleur
inc hl
inc e
ld a,e
cp 4
jr nz,next_ink
ld a,#10 ; choisit la bordure
out (c),a
ld a,BLACK
out (c),a
ret
Le Gate Array reçoit chaque couleur sous la forme #40 plus le numéro de la couleur matérielle, et le programme nomme celles qu’il emploie :
; Couleurs du Gate Array (#40 + numéro de la couleur matérielle)
BLACK = #54
BLUE = #44
SKY_BLUE = #57
BRIGHT_WHITE = #4B
RED = #4C
La palette reprend les couleurs du JavaScript : noir pour le fond, puis bleu, bleu ciel et blanc. PENS range, pour chaque groupe de 16 profondeurs, un octet de 4 pixels de la même encre :
palette:
db BLACK,BLUE,SKY_BLUE,BRIGHT_WHITE
PENS: db #FF,#0F,#F0,#F0 ; 4 pixels d'encre 3, 2, 1, 1 selon d >> 4
t: db 0
depth: db 0
ring: db 0
dot: db 0
ringX: db 0
ringY: db 0
pen: db 0
Ces octets viennent du rangement des bits du mode 1. Le bit 7 donne le poids faible de l’encre du pixel de gauche, et le bit 3 son poids fort. L’encre 1 n’allume donc que les bits 7 à 4 (#F0), l’encre 2 les bits 3 à 0 (#0F), et l’encre 3 tous les bits (#FF). L’anneau le plus proche est blanc, comme dans le JavaScript, et les plus lointains sont bleus.
Les variables reprennent les noms du JavaScript, sauf d, qui devient depth : en assembleur, d est déjà le nom d’un registre.
Attendre la VBL
La v1 dessine directement dans l’écran affiché, à #C000, que le snapshot de RASM montre déjà au départ. Pour garder une vitesse régulière, chaque image commence au retour de trame (la VBL), quand le faisceau remonte en haut de l’écran. wait_vbl lit le bit 0 du port B du PPI (#F5xx), qui vaut 1 pendant la VBL. Elle attend d’abord la fin d’une VBL déjà commencée, puis le début de la suivante :
; wait_vbl : attend le début du retour de trame.
wait_vbl:
ld b,#F5 ; PPI port B, bit 0 = VBL
vbl_end:
in a,(c)
rra
jr c,vbl_end ; déjà dans la VBL : attend sa fin
vbl_start:
in a,(c)
rra
jr nc,vbl_start
ret
La boucle
La boucle principale suit le playground. Elle attend la VBL, efface l’écran, avance t, puis dessine les 16 anneaux de 16 points. La couleur de la bordure montre le travail : rouge du début à la fin du calcul, noire pendant l’attente de la VBL. La macro border fait les deux out du changement. RASM recopie son code à chaque appel, avec {colour} remplacé par la couleur donnée :
macro border,colour
ld bc,#7F10 ; Gate Array : choisit la bordure
out (c),c
ld a,{colour}
out (c),a
mend
border RED et border BLACK encadrent donc le travail de chaque image :
main: call wait_vbl
border RED ; début du travail
ld hl,SCREEN ; l'écran affiché : l'image clignote
call clear16k
ld hl,t
inc (hl)
ld a,(hl)
neg
and 63
ld (depth),a ; anneau 0 : d = -t and 63
ld a,RINGS
ld (ring),a
ring_loop:
call ring_setup ; ringX, ringY, pen, et IX = SHAPE de l'anneau
ld a,DOTS
ld (dot),a
dot_loop:
ld a,(ringX)
add a,(ix+0) ; x = ringX + dx
ld b,a
ld a,(ringY)
add a,(ix+1) ; y = ringY + dy
ld c,a
ld a,(pen)
ld e,a
call plot
inc ix
inc ix
ld hl,dot
dec (hl)
jr nz,dot_loop
ld a,(depth) ; anneau suivant : d + 4
add a,4
and 63
ld (depth),a
ld hl,ring
dec (hl)
jr nz,ring_loop
border BLACK ; fin du travail
jp main
clear16k emploie un vieux tour du Z80. ld (hl),0 efface le premier octet, puis ldir copie chaque octet sur le suivant : 16 383 copies, et les 16 Ko sont à 0 :
clear16k: ; efface 16 Ko à partir de HL (L = 0)
ld d,h
ld e,1
ld (hl),0
ld bc,#3FFF
ldir
ret
t tient dans un octet et revient à 0 après 255. Le tunnel n’en souffre pas : il n’emploie que t and 63 et (t >> 2) and 3, qui se répètent tous les 64 images, et 256 est un multiple de 64.
Le premier anneau est à la profondeur -t and 63, comme (4 * i - t) & 63 pour i = 0 dans le JavaScript, et chaque anneau suivant est 4 profondeurs plus loin.
Un anneau
ring_setup fait pour un anneau ce que font ces lignes du JavaScript :
const c = ((d >> 2) * 64 + (((t + d) >> 2) & 63)) * 2;
const ringX = CENTER[c], ringY = CENTER[c + 1];
const base = (d * 4 + rot) * DOTS * 2;
const color = COLORS[d >> 4];
Les multiplications deviennent des décalages, et l’alignement sur 256 fait le reste :
; ring_setup : lit le centre, l'encre et l'adresse de la forme de l'anneau d.
ring_setup:
ld a,(depth)
ld c,a
and #3C ; (d >> 2) * 4
ld l,a
ld h,0
add hl,hl
add hl,hl
add hl,hl
add hl,hl
add hl,hl ; (d >> 2) * 128
ld a,(t)
add a,c
and #FC
rrca ; phase * 2, phase = ((t + d) >> 2) and 63
or l
ld l,a
ld a,h
add a,CENTER>>8
ld h,a
ld a,(hl)
ld (ringX),a
inc hl
ld a,(hl)
ld (ringY),a
ld a,c ; pen = PENS(d >> 4)
rlca
rlca
rlca
rlca
and 3
ld e,a
ld d,0
ld hl,PENS
add hl,de
ld a,(hl)
ld (pen),a
ld a,(t) ; IX = SHAPE + d * 128 + rot * 32
and #0C
rlca
rlca
rlca ; rot * 32, rot = (t >> 2) and 3
ld e,a
ld a,c
srl a ; A = d >> 1, retenue = d and 1
jr nc,even_depth
set 7,e ; + 128 si d est impair
even_depth:
add a,SHAPE>>8
ld d,a
push de
pop ix
ret
Pour CENTER, and #3C garde (d >> 2) * 4, et cinq add hl,hl multiplient encore par 32 : (d >> 2) * 128. La phase vient de t + d. and #FC met à 0 les deux bits du bas, et rrca décale d’un rang vers la droite : ((t + d) >> 2) * 2, la phase déjà multipliée par 2. Ce nombre reste sous 128, et (d >> 2) * 128 n’a que 0 ou 128 dans son octet bas : un or suffit à les réunir. Il ne reste qu’à ajouter CENTER >> 8 à l’octet haut.
Pour SHAPE, la rangée de l’anneau est à d * 128 + rot * 32. d * 128, c’est d >> 1 dans l’octet haut, et le bit 0 de d dans le bit 7 de l’octet bas. srl a fait les deux à la fois : le bit 0 de d sort dans la retenue, et set 7,e le remet à sa place.
Un point
plot reçoit x dans B, y dans C et l’octet d’encre dans E, et allume le pixel :
; plot : allume le pixel (B, C) avec l'encre E (4 pixels de la même encre).
plot: ld a,b
and 3 ; rang du pixel dans son octet
ld d,#88 ; bits du pixel de gauche
jr z,mask_ink
next_pixel:
srl d
dec a
jr nz,next_pixel
mask_ink:
ld a,d
and e
ld e,a ; bits du pixel, dans l'encre
ld a,b
srl a
srl a ; colonne d'octet : x >> 2
ld h,YLO>>8
ld l,c
add a,(hl) ; + poids faible de la ligne
ld b,a
inc h ; YHI = YLO + 256
ld a,SCREEN>>8
adc a,(hl) ; + poids fort, retenue et début de l'écran
ld h,a
ld l,b
ld a,e
or (hl) ; garde les autres pixels de l'octet
ld (hl),a
ret
Le masque du pixel part de #88, les bits 7 et 3 du pixel de gauche, et se décale d’un rang vers la droite pour chaque pixel : #44, #22, #11. Un and avec l’octet d’encre ne garde que les bits de cette encre.
L’adresse se construit en deux additions. x >> 2 plus YLO donne l’octet bas, et la retenue de cette addition passe dans l’octet haut, avec adc, qui ajoute en même temps YHI et SCREEN>>8, l’octet haut du début de l’écran (#C0). Enfin, or (hl) garde les autres pixels de l’octet : deux points voisins peuvent tomber dans le même octet.
Le tunnel sur le CPC
L’émulateur CPCEC ci-dessous lance tunnel-v1.sna, assemblé à partir de tunnel-v1-fr.asm et de TABLES.BIN :
Le tunnel tourne, avec la courbe et les couleurs du JavaScript. L’écran affiché, lu dans la mémoire de l’émulateur et comparé octet par octet à l’image calculée à partir des tables, ne montre aucun écart. Mais il avance lentement, la bordure reste rouge presque tout le temps, et l’image clignote : clear16k efface l’écran affiché pendant près de 5 trames, avant que les points ne reviennent.
Où passe le temps
Une trame du CPC dure 19 968 NOPs (un NOP vaut 1 µs : 312 lignes de 64 µs). Pour afficher 50 images par seconde, tout le travail d’une image doit tenir dans ce budget. On a mesuré le nombre de trames par image dans l’émulateur amspirit-lite (6128), sur 1 000 trames, pour la v1 et pour deux versions amputées :
| Version | Trames par image |
|---|---|
| v1 complète | 7 |
| effacement seul, sans anneau | 5 |
| 256 points, sans effacement | 2 |
Le programme attend toujours la VBL suivante, et une image coûte donc un nombre entier de trames. La v1 tourne à 50 / 7, soit un peu plus de 7 images par seconde.
L’effacement prend à lui seul presque 5 trames. ldir copie un octet en 6 NOPs (5 pour le dernier), soit 16 382 × 6 + 5 = 98 297 NOPs pour 16 Ko. Même sans aucun point, la v1 ne dépasserait pas 10 images par seconde. Les 256 points coûtent entre 1 et 2 trames, soit plus de 78 NOPs par point : un call, des variables en mémoire, une boucle de décalage pour le masque.
Dessiner moins de points ne suffira donc pas : avec cet effacement, aucun nombre de points ne tient 50 images par seconde. Or le tunnel ne touche que 256 octets au plus par image, sur les 16 384 de l’écran.
Conclusion
Le tunnel tourne sur le CPC, avec des tables que le BASIC et RASM calculent à l’identique. La v1 a les défauts de son but, qui était d’être lisible :
- 7 images par seconde au lieu de 50 ;
- une image qui clignote, car le programme efface et redessine l’écran affiché ;
- un effacement de 16 Ko, qui prend à lui seul presque 5 trames, pour au plus 256 octets allumés ;
- plus de 78 NOPs par point, entre le
call, les variables en mémoire et la boucle du masque.
Le prochain article s’attaquera à l’effacement et au clignotement, et le suivant au coût de chaque point.
Les fichiers de l’article :
- tables.bas et la disquette tables.dsk, le programme BASIC ;
- TABLES.BIN, le fichier qu’il a sauvé, header AMSDOS compris ;
- tables-fr.asm, les tables par RASM ;
- tunnel-v1-fr.asm et tunnel-v1.sna, la v1 du tunnel, assemblée par RASM 3.0.8.