← Back to Modules
module
13

Arrays and Lookup Tables

Introduction

S11 walked a string: bytes in a row, read one at a time. An array is the same thing holding numbers instead of characters, and everything you learned there applies unchanged.

What is new is the second idea in this section, and it is one of the most useful in the whole course. A lookup table replaces decisions with data. Instead of a chain of comparisons working out what to do, you index into a table and take the answer. It is shorter, it is faster, and - the part that matters for a game - you can change what the program does by editing data rather than logic.

Sprite frames, level layouts, colour maps, the sequence a monster moves in: all tables.

An array is just bytes

numbers: DEFB 10,20,30,40,50

Five bytes, one after another. Nothing else - no length stored anywhere, no type, no bounds. If you want to know how many there are, you have to remember.

Say the assembler happened to put them at 0117:

address:  0117 0118 0119 011A 011B
value:      10   20   30   40   50
index:       0    1    2    3    4

Where they actually land depends on how much code sits above them, so assemble your own with -l rather than expecting that address.

The index is not stored either. It is just how far along you are.

Reaching an element

Element n lives at base + n. So put the base in HL, add the index, and read:

        LD HL,numbers   ; base
        LD BC,2         ; index
        ADD HL,BC       ; HL = numbers + 2
        LD A,(HL)       ; A = 30

ADD HL,BC adds a 16-bit value to HL, which is what you need since addresses are 16-bit even when indexes are small.

Getting BC right

There is a trap here worth more than a passing mention, because the wrong version runs.

B and C are the two halves of BC, and B is the top half. So this:

        LD B,2
        LD C,0          ; this does NOT make BC = 2

makes BC = 512. ADD HL,BC then jumps 512 bytes past your array and LD A,(HL) reads whatever is living there - which on a freshly booted machine is FF, and later could be anything at all, including a plausible-looking number.

Nothing warns you. There is no such thing as reading past the end of an array on this machine; there is only reading, and you asked for that address.

Two ways to be safe:

        LD BC,2         ; one instruction - the assembler splits it for you

or, if the index is already in A:

        LD C,A
        LD B,0          ; index in the low half, zero in the high half

The single-instruction form is the one to prefer. Getting halves the wrong way round is not a mistake you can make if you never write the halves.

Looping with DJNZ

The counted loop from S6 was DEC C / JR NZ. There is an instruction that does both at once:

        DJNZ label

Decrement B, and Jump if Not Zero. One instruction, two bytes, and it is why B is the conventional loop counter.

Summing the array:

        ORG 256

        LD HL,numbers   ; where the data starts
        LD B,5          ; how many
        LD A,0          ; running total

sum:    ADD A,(HL)      ; add this element
        INC HL          ; step along
        DJNZ sum        ; five times

        RET

numbers: DEFB 10,20,30,40,50

A comes back as 96 - which is 150 in decimal, and 10+20+30+40+50 is 150.

Two things to hold on to. DJNZ uses B and only B, so if you need B for something else you are back to DEC/JR NZ. And it is a short jump like JR, so the loop body has to be small - which loop bodies usually are.

Lookup tables

Now the idea worth the section.

Suppose you need to turn a number 0 to 3 into a letter. You could compare your way there:

        CP 0
        JR Z,is_a
        CP 1
        JR Z,is_b
        CP 2
        JR Z,is_c
        ...

That is eight instructions and growing, and every new case means more code. Or you could put the answers in a table and index it:

        ORG 256

        LD A,2          ; the number we have
        LD C,A
        LD B,0          ; BC = the index
        LD HL,table
        ADD HL,BC
        LD A,(HL)       ; A = 'C'

        RET

table:  DEFB 'A','B','C','D'

Six instructions, and it stays six instructions whether the table has four entries or two hundred. Adding a case means adding a byte to the table and touching no code at all.

This is the technique behind most of what a game does with data. A table of sprite addresses indexed by which frame you are on. A table of screen rows indexed by Y. A table of movement offsets indexed by direction, so that "move the player" is one lookup instead of four branches. Whenever you catch yourself writing a chain of CP and JR Z, ask whether the answers could sit in a table instead.

Tables of rows

A table can have a shape. Here is one with six rows of eight:

grid:   DEFB 10,11,12,13,14,15,16,17
        DEFB 20,21,22,23,24,25,26,27
        DEFB 30,31,32,33,34,35,36,37
        DEFB 40,41,42,43,44,45,46,47
        DEFB 50,51,52,53,54,55,56,57
        DEFB 60,61,62,63,64,65,66,67

We think of it as rows and columns:

          Column
          0   1   2   3   4   5   6   7

Row 0    10  11  12  13  14  15  16  17
Row 1    20  21  22  23  24  25  26  27
Row 2    30  31  32  33  34  35  36  37
Row 3    40  41  42  43  44  45  46  47
Row 4    50  51  52  53  54  55  56  57
Row 5    60  61  62  63  64  65  66  67

The Z80 does not. In memory it is simply one long run of bytes:

10 11 12 13 14 15 16 17 20 21 22 23 24 25 26 27 ...

The rows only exist because we have decided that every eight bytes starts a new one. That decision is the whole of the layout, and it lives in how you index the table, not in the table itself.

Finding a row

Suppose we want row 5.

Each row takes eight bytes, so the rows begin at these offsets from the start of the table:

Row 0     0
Row 1     8
Row 2    16
Row 3    24
Row 4    32
Row 5    40

Row 5 begins 40 bytes from the start of the table. We worked that out with:

row * width

5 * 8 = 40

And here is the practical problem the formula hides: the Z80 has no multiply instruction. But multiplying by 8 is easy, because doubling a number three times is the same thing, and doubling is one instruction.

First put the row number into HL:

        LD HL,5         ; we want row 5

Now double it:

        ADD HL,HL       ; 5 * 2 = 10
        ADD HL,HL       ; 5 * 4 = 20
        ADD HL,HL       ; 5 * 8 = 40

HL now holds 40.

Notice that the three ADD HL,HL instructions have nothing to do with wanting row 5. There are three of them because the rows are eight bytes wide:

one doubling      x2
two doublings     x4
three doublings   x8

If we wanted row 3 instead, we would still use three doublings:

        LD HL,3

        ADD HL,HL       ; 6
        ADD HL,HL       ; 12
        ADD HL,HL       ; 24

Row 3 begins 24 bytes from the start of the table.

Finding a column

Now suppose we want row 5, column 4.

We already know that row 5 starts at offset 40:

Row 5:

Column       0   1   2   3   4   5   6   7
Value       60  61  62  63  64  65  66  67
Offset      40  41  42  43  44  45  46  47

Column 4 is another four bytes along. So:

row 5 start = 40
column      =  4
              ---
              44

The value at row 5, column 4 is 44 bytes from the beginning of the table.

That gives the general formula:

(row * row width) + column

For our example:

(5 * 8) + 4

40 + 4

44

The table's real address

The 44 is not normally an actual memory address. It is an offset: "44 bytes from the start of the table". If grid happened to start at address 10000, then:

table address   = 10000
offset          =    44
                  -----
actual address  = 10044

So there are really two stages:

1. Work out how far into the table we need to go.

   (row * width) + column

2. Add that to the address where the table begins.

   table address + offset

In code, that second stage is the ADD HL,BC you have been using all section, with the table's address in one register pair and the offset in the other.

Why eight-byte rows are useful

Eight is a particularly convenient row width on the Z80, because multiplying by eight is only three doublings:

        ADD HL,HL       ; x2
        ADD HL,HL       ; x4
        ADD HL,HL       ; x8

The same idea works for any power of two:

2-byte rows     1 doubling
4-byte rows     2 doublings
8-byte rows     3 doublings
16-byte rows    4 doublings
32-byte rows    5 doublings

This is why game data is so often arranged in powers of two.

Perhaps you only need five values in each row. You could store them as five-byte rows, but then finding a row means multiplying by five, and there is no cheap way to do that. Instead you reserve eight bytes for every row:

          Useful data        Unused

Row 0    A B C D E            . . .
Row 1    F G H I J            . . .
Row 2    K L M N O            . . .

That wastes three bytes per row, and it makes finding a row faster and simpler. It is very often the right trade. This is the first place in the course where you spend memory to buy speed, and a game does it constantly.

Change one thing

  • Change LD BC,2 to LD B,2 / LD C,0, exactly as the wrong version has it. Then look at HL in the register line and work out where it pointed. Then work out what A came back with and why it looked believable.
  • Change LD B,5 in the sum loop to LD B,4. What total do you get, and which element got left out?
  • Change it to LD B,6. What did the sixth element turn out to be?
  • Put RST 8 / DEFB 158 inside the sum loop to print each element as it is added. Do you get five characters? What are they, and why are most of them not what you would call readable?
  • Extend the lookup table to eight letters and index it with 7. Then index it with 8 and explain what you get.

Exercises

13.1 - Reach the fifth. Make an array of eight bytes with recognisable values and load the fifth element into A. Remember the fifth element is at index 4.

13.2 - Sum of ten. Sum a ten-byte array into A with DJNZ. Choose values whose total is under 256, then work out what happens if it is not.

13.3 - Digits without arithmetic. Build a table mapping 0-9 to the characters '0' to '9', and write a routine that converts a number in A using it. You already know ADD A,48 does this in one instruction - so say in a comment when the table would be the better choice anyway.

13.4 - A grid. Build a 3x3 table and write a routine that fetches the value at a given row and column using base + row*3 + col. You will have to multiply by three without a multiply instruction.

13.5 - The same grid, cheaper. Rebuild 13.4 with rows four bytes wide, wasting the fourth byte of each. Index it with ADD HL,HL. Compare the two routines by counting instructions, and decide which you would put in a game loop.

13.6 - Directions. Make a table of four movement offsets - up, down, left, right - and a routine that takes a direction 0-3 and returns the offset. This is the core of player movement, and you have just written it without a single comparison.

When it goes wrong

What you seeWhat it means
A plausible but wrong value from an arrayThe index arithmetic. Check BC: LD B,n puts n in the high half. Use LD BC,n.
A value from nowhere near your dataSame cause, further off. Read HL in the register line and compare it with the array's address in the listing.
The loop runs the wrong number of timesDJNZ counts B. Something else in the loop is using B.
error: relative jump out of range on a DJNZThe loop body is too big for a short jump. Use DEC B / JP NZ.
The total is smaller than it should beA holds one byte. Sums past 255 wrap round, and nothing tells you.

Summary

  • An array is bytes in consecutive memory. Nothing records its length or bounds - that is your job.
  • Element n is at base + n. Put the base in HL, the index in BC, and ADD HL,BC.
  • Use LD BC,n. LD B,n sets the high half and gives you an index 256 times too big, and the program runs anyway.
  • DJNZ decrements B and loops if it is not zero - the counted loop in one instruction.
  • A lookup table replaces a chain of comparisons with one index. It stays the same size however many cases there are, and you change behaviour by editing data.
  • There is no multiply instruction, so a power-of-two row width - indexed with ADD HL,HL - is worth wasting a few bytes for.

Next

S14. Tables of Addresses. Indexing worked here because every element was the same size. Strings are not - so instead of storing the things in a table, you store where they are, and addresses are always two bytes.

Get the Newsletter

New guides, disk images and community finds, roughly once a quarter. No spam, we promise, this isn't Tatung's marketing department.
Your subscription could not be saved. Please try again.
Your subscription has been successful.

Newsletter

Subscribe to our newsletter and stay updated.

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.