Chapter 13 — Layout Types

find_max and count_above work on a table where each entry is a single byte. Every entry is the same size, and the loop stepping is simple: inc hl.

Now consider a table where each entry holds three pieces of data — an x coordinate, a y coordinate and a color byte. Each entry is 3 bytes wide. The x is at offset 0 within the entry, y at offset 1, color at offset 2.

You can write that:

  ; sprite_table entry layout (manual):
  ;   offset 0: x (byte)
  ;   offset 1: y (byte)
  ;   offset 2: color (byte)
  ;   entry size: 3 bytes

  ld a, (hl)           ; read x
  inc hl
  ld a, (hl)           ; read y
  inc hl
  ld a, (hl)           ; read color

To move to the next entry, add 3 to HL. To read x from entry N, the address is sprite_table + N * 3. To read color, it is sprite_table + N * 3 + 2.

That works as long as the layout never changes. Add a field before color and every offset below it is wrong. Rename a field and every comment referring to it is stale. The code and the layout exist in two separate places — the bytes in memory, and the mental model in your head and comments — with no mechanism to keep them in sync.

AZM’s layout type system closes that gap. You describe a record once, and the assembler computes every size and offset from that description at assembly time. The CPU still performs the actual address arithmetic at run time — AZM does not generate hidden indexing code. It gives you named constants so the layout lives in one place.

AZM does not add hidden data access. It gives names to layout facts. Layout types are not runtime types — they are compile-time memory contracts, the same way AZMDoc documents register boundaries at subroutine calls. One names what crosses a call; the other names what sits at each byte offset in a record. Both keep intent explicit while the emitted machine code stays visible.


Scalar types: byte, word and addr

Before you define a record, you need names for the basic building blocks.

In AZM, byte, word and addr are layout type names:

Type Size Meaning
byte 1 byte an 8-bit value
word 2 bytes a 16-bit little-endian value
addr 2 bytes an address (same size as word; name shows intent)

You can ask the assembler how big each one is:

BYTE_SIZE  .equ sizeof(byte)    ; = 1
WORD_SIZE  .equ sizeof(word)    ; = 2
ADDR_SIZE  .equ sizeof(addr)    ; = 2

These are compile-time constants, like any .equ. They fold to plain numbers in your instructions.

When you reserve storage with .ds, you can pass a type expression directly instead of counting bytes:

OneByte:
    .ds byte              ; 1 byte

Scratch:
    .ds byte[32]          ; 32 bytes

Counter:
    .ds word              ; 2 bytes

Table:
    .ds word[8]           ; 16 bytes

.ds byte[32] means “reserve the same number of bytes as an array of 32 bytes” — 32 bytes. The brackets here describe a type shape for size calculation, not a runtime container. Nothing is initialized; .ds only reserves space. An optional fill byte still works:

Zeros:
    .ds word[8], 0        ; 16 bytes, each filled with 0

You can still write .ds sizeof(byte[32]) if you prefer the explicit form. Both mean the same thing.


Defining a record with .type

A record groups named fields into one layout. Declare it in a block:

.type Sprite
x       .byte
y       .byte
color   .byte
.endtype

.type Name opens the block. .endtype closes it. Each line names a field and gives its type.

Inside a layout block, .byte, .word and .addr are shorthands:

field .byte    ; same as: field .field byte
field .word    ; same as: field .field word
field .addr    ; same as: field .field addr

You can also write the size explicitly with .field:

.type Bullet
x       .field 1
y       .field 1
timer   .word
ptr     .addr
blob    .field 3
.endtype

.field 3 means three raw bytes with no scalar name. .word and .field word both contribute 2 bytes to the record.

Field declarations do not allocate memory. A .type block is a layout description — it tells the assembler the shape of a record so it can compute offsets and sizes. Memory comes from .db, .dw or .ds:

sprite_table:
    .ds Sprite[8]         ; space for 8 sprites

.ds Sprite[8] reserves sizeof(Sprite) * 8 bytes. The label sprite_table is an ordinary address. AZM does not permanently attach a type to it; you supply the layout when you need a constant offset (covered later in this chapter).

Named element counts

When the number of elements is a named constant, multiply explicitly — the current assembler accepts literal counts inside Type[N] for .ds, not a .equ name in those brackets:

NumSprites .equ 16

sprite_table:
    .ds NumSprites * sizeof(Sprite)   ; same bytes as .ds Sprite[16]

Use .ds Sprite[16] when the count is written as a literal in source. Use .ds Count * sizeof(Sprite) when the count lives in a .equ. Book 3’s ring buffer uses the same idea for scalar buffers: .ds RING_CAP alongside .ds byte[8] for a fixed width.

A .type block must list fields. One-line aliases such as .type Pair byte[2] are rejected — if you need a pair of bytes, write the fields:

.type Pair
lo      .byte
hi      .byte
.endtype

The older colon form (x: byte) is also not AZM syntax. Use the block form above.


sizeof and offset

Two compile-time expressions derive constants from a layout.

sizeof(Type) returns the total byte size:

SpriteSize  .equ sizeof(Sprite)        ; = 3

sizeof accepts scalar types, named records, unions and arrays:

sizeof(byte)
sizeof(word)
sizeof(Sprite)
sizeof(Sprite[16])      ; 16 * sizeof(Sprite)

offset(Type, path) returns the byte offset of a field from the start of the layout:

SpriteX     .equ offset(Sprite, x)       ; = 0
SpriteY     .equ offset(Sprite, y)       ; = 1
SpriteColor .equ offset(Sprite, color)   ; = 2

For a field inside a nested record, continue the path with dots:

.type Pos
x       .byte
y       .byte
.endtype

.type Actor
tile    .byte
pos     .field Pos
.endtype

ActorTileX  .equ offset(Actor, pos.x)    ; = 1

For an array field inside a record, put the index in brackets:

.type Scene
header  .word
sprites .field Sprite[4]
.endtype

Idx .equ 3
ThirdColor .equ offset(Scene, sprites[Idx].color)

You can also index from the array type directly:

FlagsOffset .equ offset(Sprite[16], [2].flags)

Both expressions fold to constants at assembly time. Add a field to Sprite and every sizeof and offset that refers to it updates automatically.

offset is the AZM form — there is no offsetof alias. Unknown types, unknown fields and non-constant indexes are rejected.


Using offsets in code

With the constants defined, reading a field from a record at address HL uses straightforward arithmetic:

  ; HL points to start of a Sprite record
  ld de, SpriteColor     ; DE = 2
  add hl, de             ; HL now points to the color byte
  ld a, (hl)             ; A = color

For small offsets, the IX-relative form is more compact. If IX points to the start of a Sprite:

  ld a, (ix + SpriteColor)   ; read color directly
  ld a, (ix + SpriteX)       ; read x directly

This works because SpriteColor is the constant 2, and (ix+d) accepts any signed 8-bit displacement. As long as the offset fits in one byte (0 to 127), the constants drop directly into indexed load instructions.

The offset of a later field in a larger type might exceed 127. In that case, IX-relative access fails and you need the add hl, de form instead.

For run-time indexing — “give me the Nth sprite” where N is not known until the program runs — you write the Z80 instructions that compute the address. Load the stride into DE, multiply the index by the stride, add the base address, add the field offset. AZM gives you sizeof(Sprite) and offset(Sprite, color) as named constants; the multiply and add are yours to write.


Arrays of records

To reserve space for N records, use an array type expression with .ds:

sprite_table:
    .ds Sprite[8]

That reserves exactly 8 * sizeof(Sprite) bytes. The equivalent form .ds sizeof(Sprite[8]) means the same thing.

You can also put an array inside a record:

.type Row
cells   .field byte[16]
score   .word
.endtype

RowSize  .equ sizeof(Row)              ; 16 + 2 = 18
ScoreOff .equ offset(Row, score)       ; = 16

Array stride is always sizeof(element). A record whose fields do not add up to a power of two still gets an exact packed size — AZM does not round layouts up for you.


Unions

A union declares overlapping fields that share the same memory. The union’s total size is the size of its largest member:

.union Payload
asByte  .byte
asWord  .word
.endunion

sizeof(Payload) is 2 — the size of asWord. Both fields start at offset 0. Reading asByte reads the low byte of whatever 16-bit value is stored there. Reading asWord reads both bytes as a word.

Unions can hold named types:

.type Pair
lo      .byte
hi      .byte
.endtype

.union Cell
raw     .word
pair    .field Pair
tag     .byte
.endunion

sizeof(Cell)                  ; = 2
offset(Cell, raw)             ; = 0
offset(Cell, pair.lo)         ; = 0
offset(Cell, pair.hi)         ; = 1

Alternate views of the same bytes

Unions matter when the same address should be described two ways — as a 16-bit quantity or as low/high bytes, as a raw port byte or as flag bits:

.type Pair
lo      .byte
hi      .byte
.endtype

.union WordView
raw     .word
bytes   .field Pair
.endunion

WORD_LO .equ offset(WordView, bytes.lo)
WORD_HI .equ offset(WordView, bytes.hi)

sizeof(WordView) is 2. offset(WordView, raw) and offset(WordView, bytes.lo) are both 0; offset(WordView, bytes.hi) is 1. At run time you still use plain ld / ld (hl) — the union only documents that the low byte of the word and bytes.lo share the same offset. Book 3’s bit-pattern chapter treats a status byte as flags; a union could also name raw vs flags views of one hardware register when you want both spellings in layout constants.

Unions nest inside records:

.type Packet
header  .byte
data    .field Payload
.endtype

sizeof(Packet) = sizeof(byte) + sizeof(Payload) = 1 + 2 = 3. The offset of data is 1.


Enums

An enum declares a set of named integer constants grouped under a common name:

enum Direction North, South, East, West

Members are accessed with qualified syntax:

  ld a, Direction.South    ; A = 1

Unqualified names are rejected:

  ld a, South              ; error: unqualified enum member

The qualification requirement prevents accidental name collisions when two enums share a short name. Direction.East and Axis.East can coexist.

Enums produce no memory allocation. Each member is a compile-time constant that can appear anywhere a constant is legal — instruction immediates, .equ, .db, .dw and .ds:

enum Tile Empty, Wall, Pill, Power

StartTile  .equ Tile.Pill

tile_map:
    .db Tile.Empty, Tile.Wall, Tile.Pill, Tile.Power

Member values are assigned sequentially from 0: North = 0, South = 1, East = 2, West = 3.

Enums as state and command names

Enums are not high-level data types. They are grouped constants with collision protection — named states, command bytes and token kinds that would otherwise be bare $00, $01, $02.

Store a mode byte in RAM and branch on it:

enum GameMode Title, Playing, Paused, GameOver

game_mode:
    .db GameMode.Title

    ...
    ld a, (game_mode)
    cp GameMode.Playing
    jr z, .playing
    cp GameMode.Paused
    jr z, .paused

GameMode.Playing assembles to the constant 1. The qualification prevents a short name like Playing from colliding with a label elsewhere.

Command dispatch uses the same pattern:

enum Command MoveLeft, MoveRight, Rotate, Drop

pending:
    .db Command.Rotate

    ...
    ld a, (pending)
    cp Command.Rotate
    jr z, .do_rotate

Command.Rotate is still just a byte in memory and in A. The enum carries intent for the reader and the assembler; it does not add runtime checking. For tables of handlers you would still index by that byte yourself — the enum documents which values are legal, not how to jump.


Layout cast syntax

When the base address and the layout are known at assembly time, a layout cast computes a field address in one expression:

  ld hl, <Sprite>sprite_table[0].color

This has four parts:

  • <Sprite> — the layout type to apply
  • sprite_table — the base label
  • [0] — a compile-time array index (omit when accessing a single record)
  • .color — the field path

The assembler computes sprite_table + 0 * sizeof(Sprite) + offset(Sprite, color) and substitutes the result as an immediate constant. The generated instruction loads a constant address into HL.

A higher index with an array qualifier:

  ld hl, <Sprite[8]>sprite_table[3].color

Expands to sprite_table + 3 * sizeof(Sprite) + offset(Sprite, color) = sprite_table + 9 + 2 = sprite_table + 11.

Nested fields work the same way:

  ld hl, <Actor>player.pos.x

The index inside the brackets must be a compile-time constant. A named .equ used in an expression is fine for layout-cast indexes:

BASE .equ 2
  ld hl, <Sprite[16]>sprite_table[BASE + 1].color

That is different from .ds Sprite[NumSprites] — reservation with Type[N] requires a literal N in the current assembler; use .ds NumSprites * sizeof(Sprite) for a named count.

A runtime register is not valid:

  ld hl, <Sprite>sprite_table[hl].color    ; invalid: HL is not a constant

Layout casts fold to a constant address at assembly time. <Sprite[8]>sprite_table[3].color is not a typed pointer, not a load and not runtime indexing — the assembler replaces the whole expression with one number (for example sprite_table + 11) that you could have written by hand. The CPU never sees <Sprite>; it only sees ld hl, imm16 or ld a, (imm16). If the index is not known until the program runs, you cannot use a layout cast; write the multiply-and-add in Z80 instructions yourself.

Layout casts also work inside memory operands. The parentheses are ordinary Z80 dereference syntax — they mean “byte at address”:

  ld a, (<Sprite[8]>sprite_table[3].color)

After folding, this is ld a, (sprite_table + 11), not a special typed load.

The long form and the cast form must agree:

ld hl, sprite_table + (3 * sizeof(Sprite)) + offset(Sprite, color)
ld hl, <Sprite[8]>sprite_table[3].color

Both assemble to the same constant. Use whichever reads more clearly at the call site.


A worked example: a table of 2D points

Define a record for a 2D point with integer coordinates:

.type Point
x   .byte
y   .byte
.endtype

POINT_SIZE  .equ sizeof(Point)
POINT_X     .equ offset(Point, x)
POINT_Y     .equ offset(Point, y)

NumPoints   .equ 4

points:
    .ds NumPoints * sizeof(Point)   ; 8 bytes: space for 4 points

Named counts work through ordinary expression arithmetic, not through Point[NumPoints] in .ds.

Initialize the table in ROM with four points:

  .db 10, 20    ; Point 0: x=10, y=20
  .db 30, 15    ; Point 1: x=30, y=15
  .db  5, 40    ; Point 2: x=5,  y=40
  .db 25, 25    ; Point 3: x=25, y=25

A loop that reads every x coordinate and accumulates a sum:

; In:  (no register inputs — reads from 'points' table directly)
; Out: A = sum of all x coordinates (mod 256)
; Clobbers: B, D, E, HL
sum_x_coords:
  ld hl, points          ; HL = base of points table
  ld b, NumPoints        ; B  = loop count
  ld a, 0                ; A  = running sum
  ld d, 0                ; D  = high byte for HL arithmetic
  ld e, POINT_SIZE       ; E  = stride (sizeof(Point) = 2)
SumXLoop:
  add a, (hl)            ; add x coordinate (field offset 0)
  add hl, de             ; advance HL by POINT_SIZE to next point
  djnz SumXLoop
  ret

Each iteration reads the byte at HL (which starts at points and steps by POINT_SIZE each time), accumulates it in A and advances HL to the next entry.

Reading the y coordinate instead of x requires adjusting the starting offset. Since POINT_Y = 1, add 1 to HL before the loop:

  ld hl, points + POINT_Y    ; HL = address of first y coordinate

Now the loop reads every y coordinate. The expression points + POINT_Y is computed at assembly time: points + 1.

For a two-field read (both x and y from the same entry), load x, then add 1 to HL, then load y:

ReadXYLoop:
  ld c, (hl)             ; C = x coordinate
  inc hl                 ; advance to y
  ld b, (hl)             ; B = y coordinate
  ; process C (x) and B (y) here
  inc hl                 ; advance past y to next entry
  djnz ReadXYLoop

Because sizeof(Point) = 2 and the fields are at offsets 0 and 1, each inc hl steps exactly one field. For a type with more fields, load DE with POINT_SIZE once before the loop and use add hl, de to step.

If you need a specific entry’s address at assembly time, the layout cast gives it directly:

  ld hl, <Point[4]>points[2].y    ; address of y in Point 2

The assembler computes points + 2 * sizeof(Point) + offset(Point, y) = points + 4 + 1 = points + 5 and loads that constant address into HL.


Summary

  • byte, word and addr are scalar layout types. sizeof(byte) is 1; sizeof(word) is 2.
  • .type Name / .endtype declares a packed record layout. Fields use .byte, .word, .addr or .field N. Field declarations do not allocate memory.
  • .ds TypeExpr reserves storage: .ds byte, .ds word[8], .ds Sprite, .ds Sprite[16] or .ds Count * sizeof(Sprite) for a named element count.
  • sizeof(Type) returns the exact byte size. sizeof(Sprite[16]) returns 16 * sizeof(Sprite).
  • offset(Type, path) returns a field’s byte offset. Paths can nest (pos.x) and index arrays (sprites[3].color or offset(Sprite[16], [2].flags)).
  • Use .equ to name these constants, then use the names in instructions and .ds directives.
  • Offsets that fit in a signed byte (0–127) can go directly into (ix+d) instructions.
  • <TypeExpr>label[i].field computes a constant field address. Indexes must be compile-time constants; runtime registers are rejected.
  • .union Name / .endunion declares overlapping fields. The union’s size is the size of its largest member.
  • enum Name Member1, Member2, ... defines qualified integer constants. Access them as Name.Member. Enums do not emit bytes.

Exercises

1. Compute sizes and offsets by hand. Given this type:

.type Enemy
hp      .byte
x       .word
y       .word
flags   .byte
.endtype

Without running AZM, compute sizeof(Enemy), offset(Enemy, x), offset(Enemy, y) and offset(Enemy, flags). Then write the .equ lines for each. Finally, write the .ds line that allocates space for 16 enemies using the array type form.

2. Read a field with IX. A subroutine receives a pointer to an Enemy record in IX. Write the instructions to load the hp field into A, the x field into DE (low byte in E, high byte in D) and the flags field into C. Use the symbolic offset constants from Exercise 1, not hardcoded numbers.

3. Write a layout cast. Using the Enemy type from Exercise 1, write the instruction that loads the address of the flags field of enemy_table[4] into HL, where enemy_table is the base label. Verify your answer: what numeric offset from enemy_table does this expand to?

4. Enum in a dispatch. Define an enum Command with members Move, Attack, Wait, Retreat. Write the instruction that loads the value of Command.Attack into A. Then write a comment explaining why ld a, Attack would fail to assemble.

5. Union offsets. Given WordView from this chapter (raw as .word, bytes as .field Pair), write .equ lines for WORD_LO and WORD_HI using offset. What is sizeof(WordView)? Why are offset(WordView, raw) and offset(WordView, bytes.lo) both 0?