TDD on the CPC: finding the smallest byte of an array
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:
- it puts values in the registers and in memory;
- it calls the routine;
- 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:
- Red. We write a test for a case the routine cannot handle yet. We run it: it must fail, and for the expected reason.
- Green. We write the least code that makes it pass.
- 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:
- B is 1 to 255: with B = 0,
dec bgives #FF, and the routine reads 256 bytes; - the array does not cross a page boundary, because of
inc l.
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.
Links
- z80-tdd, the test tool, and its 0.1.0 release
- z80-tdd-sorts, the repository of this post (branch
intro) - RASM, the assembler by Édouard Bergé (Roudoudou)
- Perfectly accurate Z80 flags and CPC timing, Madram / Overlanders (64NOPS), the table of durations in NOPs