Blog

TDD sur CPC : trouver le plus petit octet d'un tableau

24/10/2026Z80CPCTDD

Pour vérifier une routine Z80, on la lance et on regarde le résultat, sur les cas auxquels on pense ce jour-là. Après la dixième optimisation, cette méthode laisse passer les erreurs qui ne se voient pas à l’écran, comme une comparaison signée à la place d’une comparaison non signée, ou une boucle plus lente que prévu.

L’idée est ici de confier la vérification à des tests, avec la méthode du TDD (test-driven development, le développement piloté par les tests) : on écrit d’abord un test, puis le code qui le fait passer. Les tests restent dans le projet, et une seule commande les relance tous après chaque modification. À la fin de l’article, deux tests attrapent justement les deux erreurs citées plus haut, et le second chiffre l’écart à 15 NOPs.

On commence par une routine toute simple, qui trouve le plus petit octet d’un tableau. Le choix n’est pas dû au hasard, puisque le tri par sélection, sujet de l’article suivant, repose sur elle.

Ce que fait la routine

HL pointe sur le tableau, B contient sa longueur (de 1 à 255), et la routine rend le plus petit octet dans A.

L’algorithme lit chaque octet une fois, en gardant le plus petit rencontré. Vous pouvez lancer l’animation ou avancer étape par étape : la flèche montre l’octet lu, la case en couleur le minimum du moment.

Sur le tableau #42 #30 #05 #17 #99, le plus petit vu passe de #42 à #30, puis à #05 : la routine rend #05.

Ce que fait un test

Un test est un petit programme qui fait trois choses :

  1. il met des valeurs dans les registres et en mémoire ;
  2. il appelle la routine ;
  3. il compare le résultat à ce qu’on attend.

Quand le résultat est le bon, le test passe, et on dit qu’il est vert. Sinon il échoue, il est rouge, et il affiche ce qu’il a obtenu à la place.

Les tests tournent sur le PC. L’outil, z80-tdd, assemble le source avec RASM, charge le programme à ses vraies adresses dans un Z80 émulé, exécute la routine, puis vérifie les registres, la mémoire et la durée en NOPs. Chaque test repart d’une copie neuve des 64 Ko de mémoire, programme chargé, si bien qu’un test ne déteint jamais sur le suivant. Et si une routine part dans une boucle sans fin (cela arrive), l’outil l’arrête au bout d’un million de NOPs et affiche les dernières instructions exécutées.

Le premier test de l’article se lit ainsi :

Avant l’appel Appel Attendu
HL = #8000, B = 1, l’octet #42 en #8000 find_min A = #42

Les tests s’écrivent en TypeScript, le langage de l’outil, mais il n’est pas nécessaire de le connaître pour suivre : chaque test apparaît aussi sous la forme d’un tableau comme celui-ci. Dans la suite, le code des tests est rangé dans un bloc à déplier. Voici celui du premier, commenté ligne par ligne :

// 0x8000 s'écrit #8000 en RASM.
const ARRAY = 0x8000;

// Le nom du test, en anglais : « rend le seul octet d'un tableau d'un octet ».
it("returns the only byte of a one-byte array", () => {
  // Appelle find_min avec HL = #8000 et B = 1, après avoir écrit #42 en #8000.
  const run = program.run("find_min", {
    registers: { hl: ARRAY, b: 1 },
    memory: [{ at: ARRAY, bytes: [0x42] }],
  });

  // Attendu : A = #42.
  expect(run).toHaveRegisters({ a: 0x42 });
});

Rouge, vert, nettoyage

La méthode tient en trois temps, qu’on répète pour chaque nouveau cas :

  1. Rouge. On écrit un test pour un cas que la routine ne sait pas encore traiter. On le lance : il doit échouer, et pour la raison prévue.
  2. Vert. On écrit le moins de code possible pour qu’il passe.
  3. Nettoyage (en anglais, refactor). On améliore le code ou les tests, en vérifiant que tous les tests restent verts.

Le « moins de code possible » surprend au début, parce qu’il donne du code incomplet exprès. C’est le test suivant qui oblige à le compléter, si bien que chaque ligne de la routine finit par exister parce qu’un test l’a demandée.

Chaque étape correspond à un commit du dépôt z80-tdd-sorts, marqué d’un tag à son nom (intro/01-red, par exemple). La section Pour essayer chez vous explique comment les rejouer.

Un octet, et déjà de la triche

Commençons par le test du tableau ci-dessus, sur un tableau d’un seul octet (intro/01-red). Lancé, il échoue :

Error: The Program has no label "find_min"

L’outil ne trouve pas le label find_min, et pour cause : la routine n’existe pas encore. Le test est donc rouge pour la bonne raison. Voici le moins de code possible pour le faire passer au vert (intro/02-green) :

find_min:
 ld a,(hl)
 ret

Ce code ne cherche rien du tout, il rend le premier octet. C’est de la triche, mais le test passe, et le test suivant se charge de la démasquer.

Deux octets

À partir d’ici, HL vaut toujours #8000 et B la longueur du tableau, si bien que les tableaux de test ne montrent plus que les octets et le résultat attendu. Le deuxième test place le plus petit octet en seconde position (intro/03-red) :

Octets Attendu
#42, #17 A = #17
Le test en TypeScript
it("returns the second byte when it is the smaller one", () => {
  const run = program.run("find_min", {
    registers: { hl: ARRAY, b: 2 },
    memory: [{ at: ARRAY, bytes: [0x42, 0x17] }],
  });

  expect(run).toHaveRegisters({ a: 0x17 });
});

La routine rend toujours le premier octet, et le test échoue :

Error: Registers differ:
  A  expected #17, received #42

Le message donne ce que le test attendait (expected) et ce qu’il a obtenu (received), en hexadécimal comme sur CPC. Pour repasser au vert, une comparaison suffit, et toujours pas de boucle (intro/04-green) :

find_min:
 ld a,(hl)
 dec b
 ret z
 inc hl
 cp (hl)
 ret c          ; A is below the second byte
 ld a,(hl)
 ret

cp (hl) calcule A − (HL) sans garder le résultat, et met la retenue quand A est plus petit, ce qui suffit pour deux octets.

Cinq octets et une boucle

Le troisième test cache le plus petit octet au milieu de cinq (intro/05-red) :

Octets Attendu
#42, #30, #05, #17, #99 A = #05
Le test en TypeScript
it("returns the smallest byte wherever it is", () => {
  const run = program.run("find_min", {
    registers: { hl: ARRAY, b: 5 },
    memory: [{ at: ARRAY, bytes: [0x42, 0x30, 0x05, 0x17, 0x99] }],
  });

  expect(run).toHaveRegisters({ a: 0x05 });
});

La routine ne regarde que les deux premiers octets, et rend #30 :

  A  expected #05, received #30

Cette fois, plus moyen d’y couper, il faut une boucle (intro/06-green). C’est l’algorithme de l’animation du début :

find_min:
 ld a,(hl)
 dec b
 ret z
.loop:
 inc hl
 cp (hl)
 jr c,.next     ; A is below this byte
 ld a,(hl)
.next:
 djnz .loop
 ret

Le dec b du début compte le premier octet, déjà dans A : la boucle tourne donc B − 1 fois.

Nettoyer les tests

La routine va bien, mais les trois tests répètent la même préparation : écrire les octets en #8000, puis mettre HL et B. L’étape de nettoyage range cette préparation dans une fonction, findMin, qui reçoit les octets et calcule B elle-même, de sorte qu’aucun test ne peut plus se tromper de longueur. La routine ne change pas, et les trois tests restent verts (intro/07-refactor).

Les tests en TypeScript
// Runs find_min on `bytes`, written at ARRAY.
function findMin(bytes: number[]) {
  return program.run("find_min", {
    registers: { hl: ARRAY, b: bytes.length },
    memory: [{ at: ARRAY, bytes }],
  });
}

it("returns the only byte of a one-byte array", () => {
  expect(findMin([0x42])).toHaveRegisters({ a: 0x42 });
});

it("returns the second byte when it is the smaller one", () => {
  expect(findMin([0x42, 0x17])).toHaveRegisters({ a: 0x17 });
});

it("returns the smallest byte wherever it is", () => {
  expect(findMin([0x42, 0x30, 0x05, 0x17, 0x99])).toHaveRegisters({ a: 0x05 });
});

Deux tests de garde

Les deux tests suivants passent dès qu’on les écrit, ce qui doit mettre la puce à l’oreille : un test qui n’est jamais passé par le rouge ne prouve pas qu’il sait échouer. Pour s’en assurer, on casse la routine exprès et on vérifie que le test vire au rouge. Leurs tags portent le mot guard (garde), car chacun surveille une erreur précise.

Le premier vérifie que la comparaison se fait sans signe (intro/08-guard) :

Octets Attendu
#10, #F0 A = #10
Le test en TypeScript
it("compares the bytes as unsigned numbers: #F0 is above #10", () => {
  expect(findMin([0x10, 0xf0])).toHaveRegisters({ a: 0x10 });
});

L’erreur classique consiste à tester le signe du résultat avec jp m,.next, au lieu de la retenue avec jr c,.next. Dans ce cas, #10 − #F0 donne #20, dont le bit 7 est à 0 : le résultat paraît positif, et la routine garde #F0. Avec cette version cassée, le test passe au rouge :

  A  expected #10, received #F0

Le second vérifie que la routine laisse le tableau intact (intro/09-guard) :

Octets Attendu
#42, #30, #05, #17, #99 la mémoire inchangée
Le test en TypeScript
it("leaves the array as it found it", () => {
  expect(findMin([0x42, 0x30, 0x05, 0x17, 0x99])).toLeaveMemoryUnchanged();
});

Il suffit d’un ld (hl),a égaré après .next pour le faire échouer. Le test donne alors l’adresse de chaque octet écrasé, avec son ancienne et sa nouvelle valeur :

Error: Memory changed:
  #8003  #17 -> #05
  #8004  #99 -> #05

Un budget en NOPs

Une routine juste ne suffit pas sur CPC, où elle doit aussi tenir dans le temps qu’on lui donne, une ligne de raster ou une trame. z80-tdd compte la durée de chaque instruction en NOPs, d’après la table de Madram / Overlanders, ce qui permet à un test de fixer un budget, une durée à ne pas dépasser (intro/10-red) :

Octets Attendu
16 octets décroissants, de #FF à #F0 172 NOPs au plus

Les octets décroissants donnent le cas le plus lent : chaque octet est un nouveau minimum, donc chaque tour de boucle exécute ld a,(hl).

Le test en TypeScript
it("finds the smallest of 16 bytes in 172 NOPs at most", () => {
  // Falling bytes make every turn of the loop take its slower path.
  const falling = Array.from({ length: 16 }, (_, i) => 0xff - i);

  expect(findMin(falling).nops).toBeLessThanOrEqual(172);
});

La version actuelle dépasse ce budget :

AssertionError: expected 187 to be less than or equal to 172

Elle prend 187 NOPs, et le compte se refait à la main, instruction par instruction :

Instruction NOPs Fois Total
ld a,(hl) 2 1 2
dec b 1 1 1
ret z, qui ne sort pas 2 1 2
inc hl 2 15 30
cp (hl) 2 15 30
jr c, qui ne saute pas 2 15 30
ld a,(hl) 2 15 30
djnz, qui saute 4 14 56
djnz du dernier tour, qui ne saute pas 3 1 3
ret 3 1 3
Total 187

Pour gagner ces 15 NOPs, un grand classique suffit (intro/11-green) :

; Returns in A the smallest byte of the array at HL, B bytes long (1 to 255).
; The array stays within one 256-byte page: the loop moves HL with INC L.
find_min:
 ld a,(hl)
 dec b
 ret z
.loop:
 inc l
 cp (hl)
 jr c,.next     ; A is below this byte
 ld a,(hl)
.next:
 djnz .loop
 ret

inc l coûte 1 NOP et inc hl 2, soit 1 NOP de gagné par tour et 172 NOPs au total. En échange, le contrat en commentaire interdit au tableau de franchir une frontière de page de 256 octets. Les six tests restent verts, budget compris.

Conclusion

Onze étapes pour une routine de neuf instructions, cela peut sembler beaucoup. Mais les six tests restent dans le dépôt : si une optimisation future casse la comparaison sans signe ou dépasse le budget, pnpm test le dira tout de suite, avec le registre ou l’adresse en faute.

La routine garde deux limites, écrites dans son contrat. Aucun test ne les couvre, et c’est à l’appelant de les respecter :

L’article suivant part de cette routine pour trier un tableau entier, par sélection.

Pour essayer chez vous

Il vous faut Node.js 24 ou plus, pnpm, et RASM v3.3.1 dans le PATH. Pour rejouer une étape, clonez le dépôt z80-tdd-sorts, puis :

git checkout intro/01-red
pnpm install --no-lockfile
pnpm test

L’option --no-lockfile est nécessaire sur les tags intro/*, dont pnpm 10.34.1 et suivants refusent le lockfile (ERR_PNPM_MISSING_TARBALL_INTEGRITY). On ne corrigera pas ces tags, puisque l’article les cite.

Pour utiliser z80-tdd dans un projet à vous, installez-le avec Vitest, l’outil qui lance les tests. z80-tdd n’est pas publié sur npm, d’où l’adresse du tarball de sa release GitHub :

pnpm add -D https://github.com/IIIvan37/z80-tdd/releases/download/v0.1.0/z80-tdd-0.1.0.tgz vitest

À la racine du projet, z80-tdd.config.ts dit quel source assembler :

import { defineConfig } from "z80-tdd";

export default defineConfig({ source: "src/main.asm" });

Ce source est le vrai programme du projet, celui qu’on assemble d’habitude. Ici, il place le code en #4000 et inclut la routine à tester :

 org #4000
 include "find-min.asm"

Il reste le fichier de test, src/find-min.test.ts, où loadProgram() lance RASM une fois pour tout le fichier, puis charge le programme dans la mémoire émulée :

import { beforeAll, describe, expect, it } from "vitest";
import { loadProgram, type Program, runMatchers } from "z80-tdd";

expect.extend(runMatchers); // toHaveRegisters, toLeaveMemoryUnchanged…

describe("find_min", () => {
  let program: Program;

  beforeAll(async () => {
    program = await loadProgram();
  });

  // les tests viennent ici
});

Enfin, pnpm vitest run lance tous les tests.

Liens