TDD on the CPC: sorting pointers to strings
The last post wrote strcmp, a comparison of strings in the firmware format, with a sort in view. That sort is an insertion sort, as in the insertion sort post, but on an array of pointers: the strings do not move, and only the pointers change places. What does this detour through pointers change, for a sort we already wrote once?
Two things, and the tests bring them out one after the other. Two equal strings can at last be told apart by their address, and the stability of the sort becomes visible. And a comparison no longer costs a plain CP, because its cost depends on the letters the two strings have in common. So the budget depends on n, but also on k, the number of equal letters at the start of the two strings being compared.
The method is that of the post on find_min, to read first if TDD is new to you.
What the routine does
HL points to an array of n pointers, and B holds n, from 1 to 255. Each pointer takes two bytes, low byte first, like any address on the Z80. So the array takes 2n bytes, and it can cross a page boundary. The strings can too, as strcmp allows.
string_sort puts the pointers in the order of their strings, the order that strcmp gives. The strings do not move. Two pointers to two equal strings keep their order: the sort is stable.
The previous posts had no use for this last point. Two equal bytes are the same, and if a sort swaps them, nobody can see it. Two pointers to two “MODE” do differ: one is #9000, the other #9004. A sort that swaps them leaves a trace in the array.
Play the animation, or go step by step. Each cell shows a pointer, with the text of its string above it, and the strings follow one another from #9000, as in the tests. The cells with a coloured outline form the sorted run. The filled cell is the key, in the hole, and the arrow shows the pointer that strcmp compares it with. The animation follows the final version of the sort, written further down.
The array points to "MODE" (#9000), "MOVER" (#9004), "MOVE" (#9009), "MODE" (#900D) and "MEMORY" (#9011). Once sorted, it points to "MEMORY", then to the two "MODE" in their starting order, #9000 then #900D, then to "MOVE" and "MOVER". No string has moved.
The third key, #900D, stops behind the other “MODE” and does not move past it. That is stability, and a test will check it.
The first test
The data are keywords of CPC Basic: MODE, MOVE, MOVER, MEMORY, MERGE. They all start with M, and MOVE is the start of MOVER: strcmp must read several letters to tell them apart. The strings follow one another from #9000, and the pointers start at #8000.
The first test gives a single pointer, and expects memory to stay as it is:
| Pointers to | Expected |
|---|---|
| “MODE” | memory unchanged |
The test in TypeScript
The tests copy the bytesOf helper from the strcmp tests. runStringSort writes the strings and the pointers, then runs the Routine.
// Runs string_sort on pointers to `texts`. The strings follow one another
// from STRINGS, and the array holds a little-endian pointer to each one.
function runStringSort(texts: string[]) {
const strings = texts.map(bytesOf);
const pointers: number[] = [];
let at = STRINGS;
for (const bytes of strings) {
pointers.push(at & 0xff, at >> 8);
at += bytes.length;
}
return program.run("string_sort", {
registers: { hl: ARRAY, b: texts.length },
memory: [
{ at: ARRAY, bytes: pointers },
{ at: STRINGS, bytes: strings.flat() },
],
});
}
it("leaves a one-pointer array as it is", () => {
expect(runStringSort(["MODE"])).toLeaveMemoryUnchanged();
});
With no string_sort label, the test fails at once (strsort/01-red):
Error: The Program has no label "string_sort"
The green code is a single ret, in a new file, string-sort.asm. The main source includes it after strcmp, which the sort is going to call (strsort/02-green):
org #4000
include "strcmp.asm"
include "string-sort.asm"
Two pointers
The next test gives two pointers in the wrong order (strsort/03-red):
| Pointers to | Expected |
|---|---|
| “MOVE”, “MODE” | “MODE”, “MOVE” |
The test in TypeScript
It needs another helper: sortStrings runs the sort, then returns the texts in the order of the pointers in the array. The address computation moves into startsOf, which both helpers share.
// Runs string_sort on pointers to `texts`, and returns the texts in the order
// of the pointers the array holds after the run.
function sortStrings(texts: string[]) {
const starts = startsOf(texts);
const bytes = runStringSort(texts).memory(ARRAY, 2 * texts.length);
return texts.map((_, i) => texts[starts.indexOf(bytes[2 * i] | (bytes[2 * i + 1] << 8))]);
}
it("swaps two pointers in the wrong order", () => {
expect(sortStrings(["MOVE", "MODE"])).toEqual(["MODE", "MOVE"]);
});
AssertionError: expected [ 'MOVE', 'MODE' ] to deeply equal [ 'MODE', 'MOVE' ]
The green code compares the string of the second pointer, the key, with the string of the first one. strcmp gets the key in HL (S1) and the other string in DE (S2). It answers C if the key comes first, and the two pointers swap (strsort/04-green):
string_sort:
dec b
ret z
push hl
ld e,(hl)
inc hl
ld d,(hl) ; DE: the string of the first pointer
inc hl
ld a,(hl)
inc hl
ld h,(hl)
ld l,a ; HL: the key, the string of the second pointer
call strcmp
pop hl
ret nc ; the key does not come before the first string
ld e,(hl) ; swap the two pointers
inc hl
ld d,(hl)
inc hl
ld c,(hl)
ld (hl),e
inc hl
ld a,(hl)
ld (hl),d
dec hl
dec hl
ld (hl),a
dec hl
ld (hl),c
ret
strcmp changes HL and DE, so the address of the array goes on the stack during the call. A swap of two 16-bit words keeps four bytes in flight, in E, D, C and A.
The last pointer
The third test starts from a sorted run, with a last pointer to put at the very front (strsort/05-red):
| Pointers to | Expected |
|---|---|
| “MERGE”, “MODE”, “MOVE”, “MEMORY” | “MEMORY”, “MERGE”, “MODE”, “MOVE” |
The test in TypeScript
it("moves the last pointer to the front of a sorted run", () => {
expect(sortStrings(["MERGE", "MODE", "MOVE", "MEMORY"])).toEqual([
"MEMORY",
"MERGE",
"MODE",
"MOVE",
]);
});
AssertionError: expected [ 'MERGE', 'MODE', 'MOVE', 'MEMORY' ] to deeply equal [ 'MEMORY', 'MERGE', 'MODE', 'MOVE' ]
As in the insertion sort post, one key is enough for this test: the last one. It leaves a hole in the array. While the key comes before the string of the pointer to the left of the hole, that pointer moves into the hole, and the hole moves back one place (strsort/06-green):
string_sort:
dec b
ret z
ld e,b
ld d,0
ex de,hl
add hl,hl
add hl,de ; HL: the last pointer
ld e,(hl)
inc hl
ld d,(hl) ; DE: the key, the string to insert
dec hl
.shift:
push hl ; the hole
dec hl
ld a,(hl)
dec hl
ld l,(hl)
ld h,a ; HL: the string of the pointer before the hole
ex de,hl ; HL: the key (S1), DE: that string (S2)
push hl ; the key
call strcmp
pop de
pop hl
jr nc,.place ; the key does not come before this string
dec hl ; move the pointer before the hole into the hole
ld a,(hl)
dec hl
ld c,(hl)
inc hl
inc hl
ld (hl),c
inc hl
ld (hl),a
dec hl
dec hl
dec hl ; HL: the new hole
jr .shift
.place:
ld (hl),e
inc hl
ld (hl),d
ret
The last pointer is 2(n − 1) bytes from the start: add hl,hl doubles n − 1, then add hl,de adds the address of the array.
In .shift, HL does all the work. It reads the pointer to the left of the hole, then it becomes that pointer, so the hole goes on the stack. After ex de,hl, HL holds the key and DE the other string, as strcmp expects them. And since strcmp changes HL and DE, the key goes on the stack too. Two push and two pop for each comparison is the price of a 16-bit key.
One point matters for what follows. On two equal strings, strcmp answers Z and NC, and jr nc,.place stops the key. It never moves past an equal string.
Like the one in the insertion sort post, this loop has no bound at the start of the array, and a test further down will show it.
All the pointers
With five pointers in no order, the green code is no longer enough (strsort/07-red):
| Pointers to | Expected |
|---|---|
| “MOVE”, “MERGE”, “MOVER”, “MEMORY”, “MODE” | “MEMORY”, “MERGE”, “MODE”, “MOVE”, “MOVER” |
The test in TypeScript
it("sorts five pointers in any order", () => {
expect(sortStrings(["MOVE", "MERGE", "MOVER", "MEMORY", "MODE"])).toEqual([
"MEMORY",
"MERGE",
"MODE",
"MOVE",
"MOVER",
]);
});
AssertionError: expected [ 'MOVE', 'MERGE', 'MOVER', …(2) ] to deeply equal [ 'MEMORY', 'MERGE', 'MODE', …(2) ]
Nothing moved: the last key, MODE, comes after MEMORY, and stops at once. The outer loop inserts each pointer in turn, from the second one. The address of the key goes on the stack, under the hole, so that the next pass starts again from there (strsort/08-green):
string_sort:
dec b ; one pass for each pointer but the first
ret z
.next:
inc hl
inc hl ; HL: the next pointer to insert
push hl ; the next pass starts from there
ld e,(hl)
; …
.place:
ld (hl),e
inc hl
ld (hl),d
pop hl
djnz .next
ret
B counts the passes, and nothing in .shift touches it. The contract of strcmp pays off here: BC does not change during the call.
Two MODEs
The stability test passes as soon as it is written. It reads the addresses, because the texts are not enough (strsort/09-guard):
| Pointers to | Expected |
|---|---|
| “MODE” (#9000), “MODE” (#9004) | #9000, then #9004 |
The test in TypeScript
// The two strings are equal: only their addresses, #9000 then #9004, tell them apart.
it("keeps two pointers to equal strings in their order", () => {
expect(runStringSort(["MODE", "MODE"]).memory(ARRAY, 4)).toEqual([0x00, 0x90, 0x04, 0x90]);
});
The broken version it targets also shifts on equal strings: a jr z to the shift, right before the jr nc,.place. All the other tests pass. For a reader of the texts alone, the array is sorted, with two “MODE” side by side. Only the addresses show the swap:
AssertionError: expected [ 4, 144, +0, 144 ] to deeply equal [ +0, 144, 4, 144 ]
Across a page
The contract sets no page constraint. An inc l in place of an inc hl is a tempting shortcut, which costs 1 NOP instead of 2. It is wrong as soon as a pointer or a string crosses a page. The next guard puts everything across a page (strsort/10-guard):
| Array at | Strings at | Pointers to | Expected |
|---|---|---|---|
| #80FC, then #80FD | #90FA | “MODE”, “MOVER”, “MOVE”, “MEMORY”, “MERGE” | “MEMORY”, “MERGE”, “MODE”, “MOVE”, “MOVER” |
The test in TypeScript
The helpers now take the address of the array and that of the strings.
// The array runs into the next page: from #80FC, the page ends between two
// pointers, and from #80FD, inside one. MOVER also runs into the next page,
// from #90FE. MOVER is the key against MODE, then MOVE is the key against
// MOVER: each comparison reads past the end of the page, in one string and
// then the other.
it.each(["80FC", "80FD"])("sorts pointers and strings that cross a page, from #%s", (array) => {
expect(sortStrings(["MODE", "MOVER", "MOVE", "MEMORY", "MERGE"], parseInt(array, 16), 0x90fa)).toEqual([
"MEMORY",
"MERGE",
"MODE",
"MOVE",
"MOVER",
]);
});
The guard takes two arrays, because one is not enough. From an even address, each pointer starts at an even address, and the step from its low byte to its high byte never crosses a page: with the array at #80FC alone, five of the inc l mutants passed. From #80FD, the pointer at #80FF has its high byte at #8100.
We tried the nine broken versions, one for each inc hl or inc de of the two Routines. The guard catches eight of them. For example, an inc l in .place writes the high byte of the key at the start of the same page, and the pointer no longer leads to any string:
AssertionError: expected [ 'MODE', undefined, 'MEMORY', …(2) ] to deeply equal [ 'MEMORY', 'MERGE', 'MODE', …(2) ]
The ninth one touches the loading of the key, and the guard does not see it. The pointer across the page is that of MOVER, already in its place after MODE. The failed pass leaves this pointer where it is, and the order stays right. Another guard, further down, catches it.
The wall before the array
As in the insertion sort post, the .shift loop does not know where the array starts. A key smaller than everything reads the pointer before the array. But before #8000, the fresh memory of z80-tdd holds #00, and the pointer #0000 leads to a #00 byte. That string comes before all the others, so the key stops there, by luck.
So the test puts, right before the array, a pointer to ZONE, which comes after every string of the array (strsort/11-red):
| Before the array | Pointers to | Expected |
|---|---|---|
| a pointer to “ZONE” | “MOVE”, “MERGE”, “MOVER”, “MEMORY”, “MODE” | “MEMORY”, “MERGE”, “MODE”, “MOVE”, “MOVER”, and nothing changes outside the array |
The test in TypeScript
textsOf, taken out of sortStrings, reads the texts from a piece of memory.
it("ignores a pointer to a larger string right before the array", () => {
// The pointer right before the array, at ARRAY, points to ZONE, which comes
// after every string of the array: a shift past the first pointer would move
// it. Fresh memory would hold #0000 there, a pointer to #00, which comes before
// every string: no key would move past it.
const texts = ["ZONE", "MOVE", "MERGE", "MOVER", "MEMORY", "MODE"];
const run = program.run("string_sort", {
registers: { hl: ARRAY + 2, b: 5 },
memory: [
{ at: ARRAY, bytes: startsOf(texts, STRINGS).flatMap((at) => [at & 0xff, at >> 8]) },
{ at: STRINGS, bytes: texts.flatMap(bytesOf) },
],
});
expect(run).toChangeOnly({ address: ARRAY + 2, length: 10 });
expect(textsOf(run.memory(ARRAY + 2, 10), texts, STRINGS)).toEqual([
"MEMORY",
"MERGE",
"MODE",
"MOVE",
"MOVER",
]);
});
The test fails for the right reason. From the first pass, the key MERGE moves past ZONE: the pointer to ZONE enters the array, and the pointer to MERGE leaves it, at #8000. At the end, #8000 holds the pointer to MEMORY, #9012. Its high byte, #90, is that of ZONE, and only the low byte changes:
Error: Memory changed outside #8002-#800B:
#8000 #00 -> #12
The bound is a counter, as for insertion sort. C counts the pointers of the sorted run, and B gets a copy of it at each pass, so the key cannot move past more than B pointers. djnz .shift replaces jr .shift. The pass counter and C go on the stack, with a push bc (strsort/12-green):
string_sort:
dec b ; one pass for each pointer but the first
ret z
ld c,0
.next:
inc c ; C: the pointers of the sorted run
inc hl
inc hl ; HL: the next pointer to insert
push bc
push hl ; the next pass starts from there
ld b,c ; B: the pointers the key can still move past
ld e,(hl)
inc hl
ld d,(hl) ; DE: the key, the string to insert
dec hl
.shift:
push hl ; the hole
dec hl
ld a,(hl)
dec hl
ld l,(hl)
ld h,a ; HL: the string of the pointer before the hole
ex de,hl ; HL: the key (S1), DE: that string (S2)
push hl ; the key
call strcmp
pop de
pop hl
jr nc,.place ; the key does not come before this string
dec hl ; move the pointer before the hole into the hole
ld a,(hl)
dec hl
ld c,(hl) ; C is free: the count is on the stack
inc hl
inc hl
ld (hl),c
inc hl
ld (hl),a
dec hl
dec hl
dec hl ; HL: the new hole
djnz .shift ; at 0, the hole is the first pointer: the key goes first
.place:
ld (hl),e
inc hl
ld (hl),d
pop hl
pop bc
djnz .next
ret
C still serves in the shift: its count is on the stack, and pop bc gives it back at the end of the pass. The dec l trick of insertion sort has no counterpart here. When B reaches zero, HL already points to the hole, and .place writes in the right place.
The wall after the array
The next guard puts, right after the array, a pointer to AUTO, which comes before everything (strsort/13-guard):
| Array at | After the array | Expected |
|---|---|---|
| #80FD | a pointer to “AUTO” | nothing changes outside the array |
The test in TypeScript
it("ignores a pointer to a smaller string right after the array", () => {
// Right after the array, a pointer to AUTO, which comes before every string
// of the array: a pass too many would move it. The array starts at #80FD, so
// its second pointer lies across the end of the page: an inc l in place of an
// inc hl would read its high byte at #8000, not at #8100.
const array = 0x80fd;
const texts = ["MOVE", "MERGE", "MOVER", "MEMORY", "MODE", "AUTO"];
// …
expect(run).toChangeOnly({ address: array, length: 10 });
// …
});
One pass too many would insert AUTO, but it already fails the eight other sort tests. The real quarry of this guard is the ninth broken version from the page guard, the one that loads the key with inc l:
ld e,(hl)
inc l
ld d,(hl) ; DE: the key, the string to insert
dec hl
The array starts at #80FD, and its second pointer has its low byte at #80FF. inc l reads the high byte at #8000, then dec hl steps back to #7FFF. The pass takes #7FFF for the hole, and writes outside the array:
Error: Memory changed outside #80FD-#8106:
#7FFF #00 -> #04
With this guard, all nine inc l and inc e mutants fail.
Six ways to keep the key
Everything is green, and the code is right. But the key is expensive: each comparison puts it back on the stack, then takes it again, because strcmp changes HL and DE. Before setting the budgets, we measured other register plans. The key can live in IY, and come back to DE with ld e,iyl and ld d,iyh. These two instructions are not documented, but RASM assembles them and z80-tdd runs them. The key could also go into the operand of an ld hl,nn, through code that changes itself, but that code writes into the Program, and the two toChangeOnly tests reject it. The shift can use two LDD. And the sort can enter strcmp after its ex de,hl, at the .loop label.
Here are the six variants that ran, for n = 32. The “sorted”, “reversed” and “equal” columns are at k = 0. The “prefix” column gives a reversed input whose strings have 16 letters in common. “Per shift” and “per pass” give the cost of one shifted pointer and of one pass that stops at its first comparison, at k = 0.
| Variant | Bytes | Sorted | Reversed | Equal | Prefix | Per shift | Per pass |
|---|---|---|---|---|---|---|---|
stack, copy (13-guard) |
51 | 2 797 | 40 338 | 4 037 | 151 442 | 79 | 90 |
stack, 2 LDD |
52 | 2 797 | 41 826 | 4 037 | 152 930 | 82 | 90 |
| IY, copy | 61 | 2 952 | 39 098 | 4 192 | 150 202 | 76 | 95 |
IY, 2 LDD |
60 | 2 952 | 37 114 | 4 192 | 148 218 | 72 | 95 |
IY, 2 LDD, IY on the stack |
64 | 2 961 | 37 123 | 4 201 | 148 227 | 72 | 95 |
IY, 2 LDD, entry at strcmp.loop |
59 | 2 890 | 36 122 | 4 130 | 147 226 | 70 | 93 |
No variant wins everywhere. IY saves 3 NOPs per comparison: ld e,iyl and ld d,iyh cost 4 NOPs, against 7 for push hl and pop de. But loading the key into IY, then writing it into the hole, costs 8 more NOPs per pass. The two LDD win only if DE is free. With the key in DE, it must be saved around the copy, and the shift goes from 79 to 82 NOPs.
The entry at strcmp.loop ties the sort to the inner code of strcmp, and gives strcmp a second entry point. That leaves IY with two LDD: an 8% gain on the reversed input at k = 0, but only 2% with a 16-letter prefix, because strcmp weighs more and more. And this variant loses 5 NOPs per pass on a sorted or equal input.
We kept the version of 13-guard. It is the fastest on a sorted or equal input, tied with “stack, 2 LDD”, and the smallest. It touches neither IX nor IY.
IX and IY
The contract now says so, at the top of string-sort.asm:
; Sorts the n pointers of the array at HL, B = n from 1 to 255, in the order of
; their strings, as strcmp compares them. Each pointer takes two bytes, low byte
; first. The strings do not move. Two pointers to equal strings keep their
; order. AF, BC, DE and HL change, IX and IY do not.
A guard checks it (strsort/14-guard):
| At the start | Expected |
|---|---|
| IX = #1234, IY = #5678 | IX = #1234, IY = #5678 on exit |
The test in TypeScript
runStringSort takes a fourth parameter, the other start registers.
it("leaves IX and IY as they are", () => {
expect(
runStringSort(["MOVE", "MERGE", "MOVER", "MEMORY", "MODE"], ARRAY, STRINGS, { ix: 0x1234, iy: 0x5678 }),
).toHaveRegisters({ ix: 0x1234, iy: 0x5678 });
});
The broken version is the “IY, copy” variant of the measurement. It sorts right, and only this guard fails. IY keeps the last key, the pointer to MODE:
Error: Registers differ:
IY expected #5678, received #9014
The NOPs of a pass
Each duration comes from Madram’s table. The entry costs 5 NOPs: dec b 1, ret z not taken 2, ld c,0 2. The final ret costs 3.
Each pass pays 38 NOPs outside the comparisons. The start of .next costs 22: inc c 1, two inc hl 4, push bc 4, push hl 4, ld b,c 1, then 8 to load the key. .place costs 12, with its two pop, and djnz .next 4. The last djnz .next is not taken, and costs only 3. So the fixed part is 7 + 38(n − 1) NOPs.
A comparison costs 29 NOPs around strcmp: the two push 8, the five instructions that read the pointer 9, ex de,hl 1, call 5, the two pop 6. Let c be the cost of strcmp, its ret included. A key that stops adds jr nc taken, 3 NOPs, for 32 + c. A key that shifts adds jr nc not taken 2, the twelve instructions of the shift 24, and djnz .shift 4, for 59 + c. If the key goes first, the last djnz .shift is not taken, and that shift costs 58 + c.
At k = 0, strcmp costs 20 NOPs, less than the 29 NOPs of glue around the call.
On a sorted input, each pass makes one comparison, which stops the key after k equal letters. With c = 14k + 20, a pass costs 38 + 32 + 14k + 20, that is 90 + 14k NOPs. The total is (n − 1)(90 + 14k) + 7 NOPs.
On equal strings of L letters, the path is the same, with c = 14L + 4: (n − 1)(74 + 14L) + 7 NOPs.
On a reversed input, pass i shifts i pointers, and the key goes first. It costs 38 + i(59 + 14k + 20) − 1, that is 37 + i(79 + 14k) NOPs. The sum of i, from 1 to n − 1, is n(n − 1)/2. The total is (n − 1)(n(79 + 14k)/2 + 37) + 7 NOPs.
The budgets
A helper, withPrefix, makes the inputs of the budgets: n strings of k letters “A”, each followed by a letter that tells them apart, in order. The first budget takes them as they are, and each key stops at its first comparison (strsort/15-guard):
| n | k | Expected |
|---|---|---|
| 2, 16 and 128 | 0 and 8 | (n − 1)(90 + 14k) + 7 NOPs at most |
The test in TypeScript
// n strings with the same k letters, then a last letter that tells them
// apart, in order: each comparison finds k equal letters, then two different ones.
function withPrefix(n: number, k: number) {
return Array.from({ length: n }, (_, i) => "A".repeat(k) + String.fromCharCode(i));
}
// Each key stops at its first comparison.
it.each([
[2, 0],
[2, 8],
[16, 0],
[16, 8],
[128, 0],
[128, 8],
])("sorts %i sorted pointers after %i equal letters in (n − 1)(90 + 14k) + 7 NOPs at most", (n, k) => {
expect(runStringSort(withPrefix(n, k)).nops).toBeLessThanOrEqual((n - 1) * (90 + 14 * k) + 7);
});
The last letter goes from #00 to #7F: n stops at 128, because a letter takes 7 bits. The broken version is “IY, copy” again. It loses 5 NOPs per pass, and seven tests fail, the IX and IY one included:
AssertionError: expected 102 to be less than or equal to 97
The budget of the reversed input takes the same strings backwards (strsort/16-guard):
| n | k | Expected |
|---|---|---|
| 2, 16 and 128 | 0 and 8 | n(n − 1)/2 · (79 + 14k) + 37(n − 1) + 7 NOPs at most |
The test in TypeScript
The last parameter of runStringSort goes from the registers to the options of the Run, the limit included.
// Each key moves past the whole sorted run, with no comparison to stop it.
// 128 reversed pointers after 8 equal letters take more than a million NOPs.
it.each([
[2, 0],
[2, 8],
[16, 0],
[16, 8],
[128, 0],
[128, 8],
])("sorts %i reversed pointers after %i equal letters in n(n − 1)/2 · (79 + 14k) + 37(n − 1) + 7 NOPs at most", (n, k) => {
const run = runStringSort(withPrefix(n, k).reverse(), ARRAY, STRINGS, { nopLimit: 2_000_000 });
expect(run.nops).toBeLessThanOrEqual(((n * (n - 1)) / 2) * (79 + 14 * k) + 37 * (n - 1) + 7);
});
128 reversed pointers, after 8 letters in common, take 1 557 154 NOPs. The default limit of a Run is 1 000 000 NOPs, hence the nopLimit. The broken version is “stack, 2 LDD”: it passes the budget of the sorted input, because it changes only the shift. Here, it loses 3 NOPs per shift, and the six tests fail:
AssertionError: expected 126 to be less than or equal to 123
The third budget takes equal strings of L letters. Each key stops at its first comparison, which reads the L letters of both strings (strsort/17-guard):
| n | L | Expected |
|---|---|---|
| 2, 16 and 128 | 1 and 8 | (n − 1)(74 + 14L) + 7 NOPs at most |
The test in TypeScript
// A key never moves past a string equal to it: each key stops at its first
// comparison, which reads the L letters of both strings.
it.each([
[2, 1],
[2, 8],
[16, 1],
[16, 8],
[128, 1],
[128, 8],
])("sorts %i pointers to equal strings of %i letters in (n − 1)(74 + 14L) + 7 NOPs at most", (n, length) => {
const equal = Array.from({ length: n }, () => "A".repeat(length));
expect(runStringSort(equal).nops).toBeLessThanOrEqual((n - 1) * (74 + 14 * length) + 7);
});
The broken version adds a nop before the ret of the equal strings in strcmp. The six tests fail:
AssertionError: expected 96 to be less than or equal to 95
The three bounds are exact: each budget Run lands right on its formula, and a version slower by a single NOP fails.
One helper
The last step touches only the tests (strsort/18-refactor). The two wall tests wrote their own Run, and now go through runStringSort, which takes an options object. The five strings in no order and their sorted order become two constants, UNSORTED and SORTED. The budget of the reversed input takes the form of the two others, (n − 1)(n(79 + 14k)/2 + 37) + 7, with the same value.
The 48 tests stay green, 28 for the sort and 20 for strcmp.
Conclusion
string_sort sorts an array of pointers to strings, and the strings do not move. The tests first followed those of insertion sort on bytes, then they set what bytes could not show: two equal strings keep their order, and only their addresses prove it. A pointer or a string can cross a page, and it takes two arrays to check it. The measurement of the register plans kept the simplest version, with the key on the stack. Each budget lands right on its formula: (n − 1)(90 + 14k) + 7 NOPs on a sorted input, (n − 1)(n(79 + 14k)/2 + 37) + 7 on a reversed input, (n − 1)(74 + 14L) + 7 on equal strings.
The sort comes with its terms of use:
- 1 to 255 pointers, to strings in the firmware format, as
strcmpexpects them; - a cost in n² on a reversed input, which shared letters make heavier still: 1 557 154 NOPs for 128 pointers after 8 letters in common;
- 29 NOPs of glue around each call to
strcmp, more thanstrcmpitself at k = 0; - AF, BC, DE and HL change, IX and IY do not.
Trying it yourself
You need Node.js 24 or later, pnpm, and RASM v3.3.1 on the PATH. The steps of this post are on the strsort branch of the z80-tdd-sorts repository. It starts from strcmp/15-guard, not from main: the sort calls strcmp, as selection sort called find_min. To replay a step:
git checkout strsort/01-red
pnpm install
pnpm test
The tests of the sort are in src/string-sort.test.ts. To install z80-tdd in a project of your own, see the Trying it yourself section of the post on find_min.
Links
- The previous post:
strcmp, comparing two strings that end on bit 7 - Insertion sort on bytes, whose shape this sort follows
- Radix sort, counting sort, bubble sort and selection sort
- The introduction:
find_minand TDD on the CPC - z80-tdd, the test tool
- z80-tdd-sorts, the repository of this post (branch
strsort) - RASM, the assembler by Édouard Bergé (Roudoudou)
- Perfectly accurate Z80 flags and CPC timing, Madram / Overlanders (64NOPS), the table of durations in NOPs