Dots tunnel : la pile, les registres et le déroulage
À la fin du troisième article, le tunnel ne clignote plus et n’efface que ses propres points. Il lui faut pourtant toujours deux trames du CPC, de 19 968 NOPs chacune, pour dessiner une image. L’effacement n’y est presque plus pour rien, et le temps part dans les 256 points eux-mêmes.
Un point de la v3 passe par un call, des variables en mémoire, une boucle pour son masque et une liste d’effacement tenue par IY. On va compter ce trajet instruction par instruction, puis en retirer tout ce qui ne sert pas à écrire le point, sans changer un seul octet de l’image. Pour y arriver, la v4 confie les listes à la pile du Z80, et la v5 garde tout dans des registres et déroule les 16 points d’un anneau.
Le compte d’un point
Le troisième article comptait plus de 96 NOPs par point, sans entrer dans le détail. Le compte complet suit la boucle dot_loop et la routine plot de la v3 :
| Étape | Instructions | NOPs |
|---|---|---|
| x, y et encre | ld a,(ringX), add a,(ix+0), ld a,(ringY), add a,(ix+1), ld a,(pen) et trois ld |
25 |
| appel | call plot et ret |
8 |
| masque du pixel | and 3, la boucle next_pixel, and e |
11 à 27 |
| colonne et adresse | deux srl a, YLO, YHI, ld a,(back) |
20 |
| écriture | ld a,e, or (hl), ld (hl),a |
5 |
| liste d’effacement | ld (iy+0),l, ld (iy+1),h, deux inc iy |
16 |
| point suivant | deux inc ix, le compteur dot |
15 |
La boucle du masque tourne autant de fois que le rang du pixel dans son octet, de 0 à 3. En moyenne sur les quatre rangs, l’étape du masque coûte 18,5 NOPs, et un point en coûte 107,5. Les 256 points font donc 27 520 NOPs, plus que les 19 968 d’une trame.
Le tableau montre surtout où le temps ne sert pas à dessiner. Écrire le point, l’opération que l’image demande, coûte 5 NOPs. Tout le reste sert à trouver l’adresse, à lire des variables, à appeler une routine et à tenir des comptes.
La pile comme pointeur
La v4 s’attaque d’abord aux listes d’effacement. La v3 les parcourt avec IY, qui coûte cher : ld l,(iy+0) prend 5 NOPs pour lire un octet. Or SP, le pointeur de pile, lit ou écrit deux octets d’un coup, bien plus vite, et tout programme l’a sous la main.
pop hl lit deux octets à l’adresse SP, les range dans HL et avance SP de 2, en 3 NOPs. push hl recule SP de 2 et écrit HL à cette adresse, en 4 NOPs.
Effacer avec pop
L’effacement place SP au début de la liste, puis enchaîne 256 fois pop hl et ld (hl),c, avec C à 0 :
ld a,(erase_list) ; efface les octets allumés deux images plus tôt
ld h,a
ld l,0
ld sp,hl ; SP parcourt la liste
ld c,0
repeat 256 ; un pop par adresse, sans compteur
pop hl
ld (hl),c
rend
; SP pointe maintenant sur la fin de la liste : le push de chaque point la
; remplit de nouveau, en descendant. Jusqu'à `jp main`, ni call ni autre push.
repeat 256 est une directive de RASM (tout assembleur correct en propose une semblable) : elle recopie 256 fois les deux instructions, sans compteur ni saut. Une adresse coûte alors 5 NOPs au lieu de 23, et la liste entière 1 280 NOPs au lieu de 5 887. Le prix se paie en mémoire, avec 512 octets de code. Le 0 attend dans C, car ld (hl),c prend 2 NOPs, contre 3 pour ld (hl),0.
Noter avec push
À la fin de l’effacement, SP pointe juste après la liste. C’est exactement là qu’il faut commencer à la réécrire, mais en descendant : chaque push hl recule SP avant d’écrire. Après 256 points, SP est revenu au début de la liste, et le prochain effacement de cet écran la lira dans l’autre sens. L’ordre des adresses ne compte pas, puisque toutes reçoivent un 0.
Les quatre instructions de la v3 qui notaient l’adresse deviennent donc une seule, et la liste passe de 16 à 4 NOPs par point :
or (hl) ; garde les autres pixels de l'octet
ld (hl),a
push hl ; note l'adresse pour l'effacement
Ce que la pile interdit
Une fois SP dans la liste, le programme n’a plus de pile. Un call y écrirait son adresse de retour, au milieu des adresses à effacer, et le ret repartirait d’une adresse de l’écran. Jusqu’à la fin de l’image, le programme doit respecter deux règles :
- les interruptions restent coupées, car le Z80 empilerait l’adresse du programme interrompu (le tunnel tourne en
didepuis la v1) ; - aucun
call, et aucunpushautre que celui du point.
plot et ring_setup entrent donc dans la boucle, à la place de leurs call. Le point y gagne au passage les 8 NOPs du call et du ret. ring_setup finissait par push de et pop ix pour copier DE dans IX, et la v4 le fait sans la pile :
even_depth:
add a,SHAPE>>8
ld d,a
ld ix,0
add ix,de ; IX = DE, sans push ni pop
La boucle remet la vraie pile en place au début de chaque image, avant d’appeler wait_vbl et show_screen :
main: ld sp,STACK ; le tracé a laissé SP dans une liste
call wait_vbl
ld a,(work)
call show_screen ; affiche l'écran qu'on vient de dessiner
ld a,(work)
xor SCREEN_C0 ^ SCREEN_80
ld (work),a ; et dessine dans l'autre
On a profité de la réécriture pour renommer back en work, l’écran de travail, et pour donner un nom aux octets hauts des deux écrans, SCREEN_C0 et SCREEN_80. RASM calcule SCREEN_C0 ^ SCREEN_80, le ou exclusif des deux, au moment d’assembler. Le résultat vaut #40, la constante que la v3 écrivait en dur.
Le résultat
tunnel-v4.sna dessine les mêmes images que la v1, octet pour octet. On l’a vérifié comme pour la v2 et la v3 : dans un émulateur Z80, en comparant les 16 Ko de l’écran de travail pour les 256 valeurs de t.
Il reste pourtant à 2 trames par image. Un point coûte maintenant 107,5 − 8 − 12 = 87,5 NOPs, et les 256 points en font 22 400, toujours plus qu’une trame. L’effacement est presque gratuit, mais le dessin seul déborde encore.
Tout en registres
La v5 s’occupe du point lui-même. Dans la v4, chaque point relit ringX, ringY, pen et work en mémoire, à 4 NOPs la lecture, alors que ces valeurs ne changent qu’une fois par anneau, voire par image. Le Z80 a assez de registres pour les garder toutes, à condition d’utiliser ses demi-registres d’index.
Les registres
IX et IY se coupent en deux moitiés de 8 bits, IXH et IXL, IYH et IYL. Zilog ne les a jamais documentées, mais elles marchent sur le Z80 de tous les CPC, et RASM les accepte. La v5 répartit ses valeurs ainsi :
| Registre | Contenu | Lecture |
|---|---|---|
| DE | pointeur dans la forme de l’anneau | ld a,(de) : 2 NOPs |
| IXL, IXH | centre x et y de l’anneau | add a,ixl : 2 NOPs |
| IYH | octet haut de l’écran de travail | ld a,iyh : 2 NOPs |
| IYL | anneaux restants | dec iyl : 2 NOPs |
| B | encre de l’anneau, sur les quatre pixels | and b : 1 NOP |
| A’ | bits du pixel, mis de côté | ex af,af' : 1 NOP |
DE remplace IX pour parcourir la forme. Les 32 octets d’un anneau restent dans une même page de 256 octets, car leur octet bas va au plus jusqu’à 128 + 96 + 31 = 255. inc e suffit donc pour avancer, en 1 NOP, contre 3 pour inc ix.
A’ est la copie de A que le Z80 garde en réserve. ex af,af' échange A et F avec leurs copies, en 1 NOP, et sert à garder un octet sans toucher à un autre registre.
L’abscisse dans deux tables
La boucle next_pixel de la v3 calcule les bits du pixel en décalant #88 autant de fois que le rang du pixel. Puisque x tient dans un octet, deux tables de 256 octets donnent d’un coup les bits du pixel et sa colonne d’octet. XMASK et COLX occupent #7A00 à #7BFF, dans le bloc libre entre les tables du BASIC et les listes.
make_x_tables les remplit une fois, au démarrage, et TABLES.BIN reste donc celui du deuxième article. Pour chaque x, la routine refait le calcul de plot :
; make_x_tables : pour chaque x de 0 à 255, XMASK(x) = les bits du pixel
; x and 3 dans son octet (#88 décalé x and 3 fois à droite), COLX(x) = x >> 2.
make_x_tables:
ld hl,XMASK
next_x: ld a,l
and 3
ld b,a
ld a,#88 ; bits du pixel de gauche
jr z,mask_done
shift_mask:
srl a
djnz shift_mask
mask_done:
ld (hl),a
inc h ; COLX
ld a,l
srl a
srl a
ld (hl),a
dec h
inc l
jr nz,next_x
ret
L contient x, et H passe d’une table à l’autre. ld b,a ne touche pas aux drapeaux, et jr z teste donc encore le résultat de and 3. inc l repasse à 0 après 255, et jr nz s’arrête là.
Seize points à la suite
Le compteur dot coûtait 9 NOPs par point. repeat DOTS le supprime en recopiant 16 fois le code d’un point. La boucle des anneaux reste une vraie boucle, puisqu’elle ne tourne que 16 fois par image. Voici le point de la v5 :
repeat DOTS ; les 16 points, l'un après l'autre
ld a,(de) ; dx
inc e ; les 32 octets d'un anneau restent dans une page
add a,ixl ; x = centre x de l'anneau + dx
ld l,a
ld h,XMASK>>8
ld a,(hl) ; bits du pixel dans son octet
and b ; dans l'encre
ex af,af' ; mis de côté pour la fin
inc h ; COLX = XMASK + 256
ld c,(hl) ; colonne d'octet : x >> 2
ld a,(de) ; dy
inc e
add a,ixh ; y = centre y de l'anneau + dy
ld l,a
ld h,YLO>>8
ld a,(hl)
add a,c ; poids faible de la ligne + colonne
ld c,a
inc h ; YHI = YLO + 256
ld a,iyh
adc a,(hl) ; + poids fort, retenue et début de l'écran
ld h,a
ld l,c
ex af,af' ; de nouveau les bits du pixel
or (hl) ; garde les autres pixels de l'octet
ld (hl),a
push hl ; note l'adresse pour l'effacement
rend
inc h ne touche pas à la retenue : celle de add a,c arrive intacte à adc a,(hl), comme dans la v3. Le point tient en 43 NOPs :
| Étape | NOPs |
|---|---|
| x : bits du pixel et colonne | 15 |
| y | 8 |
| adresse | 11 |
| écriture et liste | 9 |
Le code de la v5 pèse 1 315 octets, contre 376 pour la v3 et 859 pour la v4. Les 16 copies du point en prennent 512, et l’effacement déroulé autant.
Le tunnel sur le CPC
L’émulateur CPCEC ci-dessous lance tunnel-v5.sna, assemblé à partir de tunnel-v5-fr.asm :
La bordure rouge ne couvre plus qu’une partie de la hauteur, et elle reste immobile : le tunnel suit chaque trame. L’écran dessiné est là encore identique, octet pour octet, à celui de la v1 pour les 256 valeurs de t.
Ce que rapporte chaque version
On a mesuré les trois versions comme dans le troisième article, en trames par image dans amspirit-lite-headless (6128), sur 1 000 trames :
| Version | Trames par image | Images par seconde |
|---|---|---|
| v3 : liste parcourue par IY | 2 | 25 |
| v4 : liste parcourue par la pile | 2 | 25 |
| v5 : registres, tables d’abscisse, points déroulés | 1 | 50 |
Le compte à la main explique le saut. La v5 dépense 1 280 NOPs pour l’effacement et 256 × 43 = 11 008 pour les points. La préparation d’un anneau (centre, encre et forme) prend environ 100 NOPs, soit environ 1 650 pour les 16. L’image entière tient en environ 14 000 NOPs, 70 % d’une trame.
Le même compte dit combien de points la v5 pourrait tracer en une trame. Un point de plus coûte 48 NOPs : 43 pour le tracer et 5 pour l’effacer. Un anneau de 16 points coûte donc 16 × 48 + 100, environ 870 NOPs. Il reste un peu moins de 6 000 NOPs dans la trame, de quoi ajouter 6 anneaux : 22 anneaux, soit 352 points, tiendraient encore dans une trame.
Ce chiffre reste théorique, pour deux raisons. D’abord, chaque liste d’effacement ne garde que 256 adresses. Ensuite, les 64 profondeurs, prises de 4 en 4, ne donnent que 16 anneaux différents : un dix-septième anneau retomberait sur le premier.
Conclusion
La v5 tourne à 50 images par seconde, le maximum du CPC, avec les mêmes images que la v1. Plusieurs limites restent :
- près de 30 % de la trame restent libres, et le tunnel n’affiche toujours que 256 points ;
- sur les 43 NOPs d’un point, 19 servent encore à calculer y puis l’adresse, avec deux tables, une addition et sa retenue ;
- le code a plus que triplé, de 376 à 1 315 octets.
Le prochain article change la forme de l’écran, en 256 × 256 pixels, pour que l’adresse d’un point se calcule sur 8 bits, puis cherche combien de points tiennent dans une trame.
Les fichiers de l’article :
- tunnel-v4-fr.asm et tunnel-v4.sna, la v4 avec la pile ;
- tunnel-v5-fr.asm et tunnel-v5.sna, la v5 en registres ;
- TABLES.BIN, les tables du BASIC, que les deux versions chargent.
Les deux versions s’assemblent avec RASM 3.0.9.