Blog

TDD on the CPC: finding the smallest byte of an array

24/10/2026Z80CPCTDD

To check a Z80 routine, we run it and look at the result, for the cases we happen to think of that day. After the tenth optimisation, that method lets through the mistakes the screen never shows, such as a signed comparison in place of an unsigned one, or a loop slower than planned.

The idea here is to hand the check over to tests, with TDD (test-driven development): we write a test first, then the code that makes it pass. The tests stay in the project, and a single command runs them all again after each change. By the end of the post, two tests catch exactly the two mistakes above, and the second one puts a number on the gap: 15 NOPs.

We start with a simple routine, which finds the smallest byte of an array. The choice is not random, since selection sort, the subject of the next post, is built on it.

What the routine does

HL points at the array, B holds its length (1 to 255), and the routine returns the smallest byte in A.

The algorithm reads each byte once, keeping the smallest it has met. You can play the animation or go step by step: the arrow shows the byte being read, the coloured box the current minimum.

On the array #42 #30 #05 #17 #99, the smallest seen goes from #42 to #30, then to #05: the routine returns #05.

What a test does

A test is a small program that does three things:

  1. it puts values in the registers and in memory;
  2. it calls the routine;
  3. it compares the result with what we expect.

When the result is right, the test passes, and we say it is green. Otherwise it fails, it is red, and it shows what it got instead.

The tests run on the PC. The tool, z80-tdd, assembles the source with RASM, loads the program at its real addresses into an emulated Z80, runs the routine, then checks the registers, the memory and the duration in NOPs. Each test starts from a fresh copy of the 64 KB of memory, program loaded, so one test never leaks into the next. And if a routine gets stuck in an endless loop (it happens), the tool stops it after one million NOPs and prints the last instructions it executed.

The first test of the post reads like this:

Before the call Call Expected
HL = #8000, B = 1, the byte #42 at #8000 find_min A = #42

The tests are written in TypeScript, the language of the tool, but there is no need to know it to follow along: each test also comes as a table like this one. From here on, the code of the tests sits in a block to unfold. Here is the first one, commented line by line:

// 0x8000 is #8000 in RASM.
const ARRAY = 0x8000;

// The name of the test.
it("returns the only byte of a one-byte array", () => {
  // Calls find_min with HL = #8000 and B = 1, after writing #42 at #8000.
  const run = program.run("find_min", {
    registers: { hl: ARRAY, b: 1 },
    memory: [{ at: ARRAY, bytes: [0x42] }],
  });

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

Red, green, refactor

The method comes down to three beats, repeated for each new case:

  1. Red. We write a test for a case the routine cannot handle yet. We run it: it must fail, and for the expected reason.
  2. Green. We write the least code that makes it pass.
  3. Refactor. We clean up the code or the tests, and check that every test stays green.

The “least code” part is surprising at first, because it gives code that is incomplete on purpose. The next test is what forces us to complete it, so each line of the routine ends up there because a test asked for it.

Each step matches a commit of the z80-tdd-sorts repository, marked with a tag of its own (intro/01-red, for instance). The section Trying it yourself explains how to replay them.

One byte, and already a cheat

Let us start with the test in the table above, on an array of a single byte (intro/01-red). When we run it, it fails:

Error: The Program has no label "find_min"

The tool cannot find the find_min label, and for good reason: the routine does not exist yet. So the test is red for the right reason. Here is the least code that turns it green (intro/02-green):

find_min:
 ld a,(hl)
 ret

This code searches nothing at all, it returns the first byte. It is a cheat, but the test passes, and the next test takes care of exposing it.

Two bytes

From here on, HL is always #8000 and B the length of the array, so the test tables show only the bytes and the expected result. The second test puts the smaller byte in second place (intro/03-red):

Bytes Expected
#42, #17 A = #17
The test in 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 });
});

The routine still returns the first byte, and the test fails:

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

The message gives what the test expected and what it received, in hexadecimal as on the CPC. To turn green again, one comparison is enough, and still no loop (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) computes A − (HL) and throws the result away, setting the carry when A is the smaller one, which is enough for two bytes.

Five bytes and a loop

The third test hides the smallest byte in the middle of five (intro/05-red):

Bytes Expected
#42, #30, #05, #17, #99 A = #05
The test in 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 });
});

The routine looks only at the first two bytes, and returns #30:

  A  expected #05, received #30

This time there is no way around it: we need a loop (intro/06-green). It is the algorithm of the animation at the top:

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

The dec b at the top counts the first byte, already in A, so the loop runs B − 1 times.

Refactoring the tests

The routine is fine, but the three tests repeat the same set-up: write the bytes at #8000, then set HL and B. The refactor step moves that set-up into a function, findMin, which takes the bytes and works out B itself, so no test can get the length wrong any more. The routine does not change, and the three tests stay green (intro/07-refactor).

The tests in 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 });
});

Two guard tests

The next two tests pass as soon as they are written, which should ring a bell: a test that has never been red does not prove it can fail. To make sure, we break the routine on purpose and check that the test turns red. Their tags carry the word guard, because each one watches for one specific mistake.

The first one checks that the comparison is unsigned (intro/08-guard):

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

The classic mistake is to test the sign of the result with jp m,.next, instead of the carry with jr c,.next. In that case, #10 − #F0 gives #20, whose bit 7 is 0: the result looks positive, and the routine keeps #F0. With this broken version, the test turns red:

  A  expected #10, received #F0

The second one checks that the routine leaves the array alone (intro/09-guard):

Bytes Expected
#42, #30, #05, #17, #99 the memory unchanged
The test in TypeScript
it("leaves the array as it found it", () => {
  expect(findMin([0x42, 0x30, 0x05, 0x17, 0x99])).toLeaveMemoryUnchanged();
});

One stray ld (hl),a after .next is enough to make it fail. The test then gives the address of every byte the routine overwrote, with its old and new value:

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

A NOP budget

A correct routine is not enough on the CPC, where it also has to fit in the time it is given, a raster line or a frame. z80-tdd counts the duration of each instruction in NOPs, from the table by Madram / Overlanders, which lets a test set a budget, a duration not to exceed (intro/10-red):

Bytes Expected
16 falling bytes, from #FF to #F0 172 NOPs at most

Falling bytes give the slowest case: each byte is a new minimum, so each turn of the loop runs ld a,(hl).

The test in 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);
});

The current version goes over that budget:

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

It takes 187 NOPs, and the count can be redone by hand, one instruction at a time:

Instruction NOPs Times Total
ld a,(hl) 2 1 2
dec b 1 1 1
ret z, which does not return 2 1 2
inc hl 2 15 30
cp (hl) 2 15 30
jr c, which does not jump 2 15 30
ld a,(hl) 2 15 30
djnz, which jumps 4 14 56
djnz of the last turn, which does not jump 3 1 3
ret 3 1 3
Total 187

To win these 15 NOPs, a great classic is enough (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 costs 1 NOP and inc hl 2, which saves 1 NOP per turn and brings the total down to 172. In exchange, the contract in the comment forbids the array to cross a 256-byte page boundary. All six tests stay green, budget included.

Conclusion

Eleven steps for a routine of nine instructions may look like a lot. But the six tests stay in the repository: if a future optimisation breaks the unsigned comparison or blows the budget, pnpm test will say so at once, with the faulty register or address.

The routine keeps two limits, written in its contract. No test covers them, and the caller must respect them:

The next post starts from this routine to sort a whole array, by selection.

Trying it yourself

You need Node.js 24 or later, pnpm, and RASM v3.3.1 on the PATH. To replay a step, clone the z80-tdd-sorts repository, then:

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

The --no-lockfile option is needed on the intro/* tags, whose lockfile pnpm 10.34.1 and later refuse (ERR_PNPM_MISSING_TARBALL_INTEGRITY). We will not fix these tags, since this post cites them.

To use z80-tdd in a project of your own, install it with Vitest, the tool that runs the tests. z80-tdd is not published on npm, hence the address of the tarball of its GitHub release:

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

At the root of the project, z80-tdd.config.ts says which source to assemble:

import { defineConfig } from "z80-tdd";

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

That source is the project’s real program, the one you assemble every day. Here it puts the code at #4000 and includes the routine under test:

 org #4000
 include "find-min.asm"

That leaves the test file, src/find-min.test.ts, where loadProgram() runs RASM once for the whole file, then loads the program into the emulated memory:

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();
  });

  // the tests go here
});

Finally, pnpm vitest run runs all the tests.