Chapter 5 — Records

Chapter 2 indexed bytes in a table. Each element was one byte wide, so stride was always 1 and offsets were obvious. Real programs store records: several fields packed together — coordinates, queue indices, flags — with a stride larger than 1.

You can keep field offsets in comments and hope they stay correct. Wirth’s advice is the opposite: fix the representation first, then write the algorithm against that layout. AZM’s .type blocks are that representation. You describe the record once; sizeof and offset supply the numbers your instructions need.

This chapter reviews layout types from Book 1 Chapter 13, applies them to field reads and writes through HL and IX and builds a ring buffer — a fixed-size FIFO queue over a byte table — as the main worked example. The companion listing is examples/05_ring_buffer.asm.


The problem: a queue without moving memory

A FIFO queue (first in, first out) needs:

  • storage for N elements
  • a write index (where the next push goes)
  • a read index (where the next pop comes from)
  • a count of how many elements are valid (or equivalent logic)

Shifting the whole table on every pop is wasteful on a small machine. A ring buffer keeps indices in workspace RAM and only moves the indices. Storage is a fixed byte array; push writes at head and advances; pop reads at tail and advances. When an index reaches capacity, it wraps to 0.

No allocator, no linked list nodes — just bytes, offsets and compare/branch. That is the Book 3 sweet spot: representation before algorithm, with every memory access visible.


Define the layout once

A record is a packed field list inside .type / .endtype:

.type RingState
head    .byte
tail    .byte
count   .byte
.endtype

Field lines do not allocate memory. They only describe shape. Storage still comes from .ds, .db or .dw:

RING_CAP .equ 8

ring_buf:
    .ds RING_CAP

ring_state:
    .ds RingState

ring_buf reserves eight data bytes. ring_state reserves sizeof(RingState) bytes — three bytes for head, tail and count in order. When the length is a named constant, .ds RING_CAP and .ds byte[8] mean the same reservation; the type-array form uses literal lengths in the current assembler, not named constants.

Name the compile-time constants you will use in instructions:

RING_HEAD   .equ offset(RingState, head)
RING_TAIL   .equ offset(RingState, tail)
RING_COUNT  .equ offset(RingState, count)
STATE_SIZE  .equ sizeof(RingState)

If you add a field to RingState, reassemble and every .equ that uses offset updates. The algorithm code keeps symbolic names instead of hardcoded 0, 1, 2.


sizeof and .ds Type[n]

sizeof(Type) is the record’s exact packed size in bytes. For scalars:

Type sizeof
byte 1
word 2
addr 2

For arrays in size positions, literal lengths multiply:

BUF_BYTES .equ sizeof(byte[8])   ; = 8

If the capacity is a named constant, use the constant directly:

BUF_BYTES .equ RING_CAP

.ds accepts a type expression wherever it needs a byte count:

ring_buf:
    .ds RING_CAP

ring_state:
    .ds RingState

These forms are equivalent to .ds 8 and .ds 3 here. With a literal length you can also write .ds byte[8]; that documents element width when capacity is fixed in source. Initialized data still uses .db / .dw; .ds only reserves space.

Labels stay untyped. ring_state is an address, not a permanent RingState variable. You pass that address in a register and use offset(RingState, field) constants at the access site — same rule as in the AZM layout design docs.


Reading and writing fields

HL plus offset

When HL points at the start of a RingState record:

  ld de, RING_COUNT
  add hl, de
  ld a, (hl)            ; A = count

For offsets 0–127, the constant fits in (ix + d) form, which is usually shorter.

IX-relative access

Load the record base into IX once, then use symbolic displacements:

  ld ix, ring_state
  ld a, (ix + RING_HEAD)
  ld (ix + RING_COUNT), a

RING_HEAD is the constant 0; RING_TAIL is 1; RING_COUNT is 2. The assembler substitutes the numeric displacement; the Z80 encodes (ix + 0) as (ix + 0) and so on.

This is the pattern queue routines use: IX holds ring_state for the whole push/pop; HL walks ring_buf when the routine needs base + index.

Run-time index into the byte table

head and tail are dynamic indices (0 .. RING_CAP−1). To address ring_buf[head]:

  ld a, (ix + RING_HEAD)
  ld hl, ring_buf
  ld b, 0
  ld c, a
  add hl, bc            ; HL = ring_buf + head
  ld a, e               ; byte to store (saved in E)
  ld (hl), a

AZM does not emit multiply/add for runtime indices. Layout types give you field offsets and record sizes; index × stride and table base + offset remain ordinary Z80 instructions — by design, so the machine stays visible.


Layout casts for constant addresses

When the index and field path are known at assembly time, a layout cast folds the address into one expression:

  ld hl, <RingState>ring_state.count

Parts:

  • <RingState> — layout to apply
  • ring_state — base label
  • .count — field path (no [i] when accessing a single record)

The assembler computes ring_state + offset(RingState, count) and emits ld hl, imm16.

For an array of records with a constant index:

  ld hl, <byte[8]>ring_buf[3]

That is ring_buf + 3 when the element type is byte. For a table of structures:

  ld hl, <Sprite[16]>sprite_table[2].flags

expands to sprite_table + 2 * sizeof(Sprite) + offset(Sprite, flags).

Runtime registers are rejected inside the brackets:

  ld hl, <byte[8]>ring_buf[hl]    ; error: HL is not a constant

Use layout casts at call sites where the index is fixed (initialization, debug checks, table-driven dispatch with .equ indices). Use HL/BC arithmetic when the index lives in a register during push/pop.

The long form and the cast must agree:

  ld hl, ring_state + offset(RingState, count)
  ld hl, <RingState>ring_state.count

Ring buffer structure

Separate data (the ring) from control (indices and count):

.type RingState
head    .byte       ; next write index
tail    .byte       ; next read index
count   .byte       ; bytes currently stored
.endtype

ring_buf:
    .ds RING_CAP

ring_state:
    .ds RingState

Invariants (when the routines are correct):

  • 0 <= count <= RING_CAP
  • head and tail are each in 0 .. RING_CAP - 1
  • the oldest byte is at ring_buf[tail] when count > 0
  • the next free slot for push is ring_buf[head] when count < RING_CAP

Push fails closed when count == RING_CAP (returns with carry clear). Pop fails when count == 0. The companion program documents that policy in AZMDoc.

Memory diagram

After pushing $11, $22, $33 and then popping all three, the buffer may still hold those bytes in RAM, but count is 0 and the logical queue is empty:

  ring_buf ($8000)          ring_state ($8008)
  ┌───┬───┬───┬───┬───┬───┬───┬───┐   ┌──────┬──────┬───────┐
  │11 │22 │33 │   │   │   │   │   │   │ head │ tail │ count │
  └───┴───┴───┴───┴───┴───┴───┴───┘   │  3   │  3   │   0   │
    0   1   2   3   4   5   6   7       └──────┴──────┴───────┘
              ▲
              └── head and tail both advanced past the consumed cells

After three more pushes without pops, count is 3 again and head points at the next free cell while tail marks the oldest live byte:

flowchart LR
  subgraph buf["ring_buf[0..7]"]
    t["tail → oldest"]
    h["head → next write"]
  end
  subgraph st["ring_state"]
    T[tail]
    H[head]
    C[count]
  end
  T -.-> t
  H -.-> h

When head or tail would become RING_CAP, wrap to 0:

@ring_advance_index:
    inc a
    cp RING_CAP
    ret c                 ; still in range
    xor a                 ; wrap to 0
    ret

If RING_CAP is a power of two (8, 16, 32, …), you can replace cp / xor with and RING_CAP - 1 after inc a — one instruction wrap. The compare form works for any capacity and is what the example uses.


ring_push and ring_pop

Push

; ring_push: append one byte; carry set on success, carry clear when full
;!      in        A, IX
;!      out       carry
;!      clobbers  BC, DE, HL
@ring_push:
    ld e, a
    ld a, (ix + RING_COUNT)
    cp RING_CAP
    jr nc, .full
    ld a, (ix + RING_HEAD)
    ld hl, ring_buf
    ld b, 0
    ld c, a
    add hl, bc
    ld a, e
    ld (hl), a
    ld a, (ix + RING_HEAD)
    call ring_advance_index
    ld (ix + RING_HEAD), a
    ld a, (ix + RING_COUNT)
    inc a
    ld (ix + RING_COUNT), a
    scf
    ret
RingPushFull:
    or a
    ret

The byte to store starts in A; the routine moves it to E while using A for comparisons and loads. Carry flag is the success/fail signal — no separate error code byte unless the caller wants one in workspace.

Pop

; ring_pop: remove oldest byte; carry set on success, carry clear when empty
;!      in        IX
;!      out       A, carry
;!      clobbers  BC, DE, HL
@ring_pop:
    ld a, (ix + RING_COUNT)
    or a
    jr z, .empty
    ld a, (ix + RING_TAIL)
    ld hl, ring_buf
    ld b, 0
    ld c, a
    add hl, bc
    ld e, (hl)
    ld a, (ix + RING_TAIL)
    call ring_advance_index
    ld (ix + RING_TAIL), a
    ld a, (ix + RING_COUNT)
    dec a
    ld (ix + RING_COUNT), a
    ld a, e
    scf
    ret
RingPopEmpty:
    or a
    ret

FIFO order: bytes leave in the same order they arrived because tail chases head around the ring.


AZMDoc on routines

Book 1 Chapter 12 introduced AZMDoc: semicolon comments with ;! tags for register contracts. Book 3 algorithm routines should always carry them.

Tag Meaning
;! in Registers the caller must set before call
;! out Registers guaranteed on success
;! clobbers Registers destroyed (not restored)

Callable entries use @name: so the register-care analyzer knows where a routine body starts (AZM assembly baseline). Call sites still say call ring_push, not call @ring_push.

For ring_push and ring_pop, put success/failure meaning in the human ; line and name the carrier in ;! out as carry (not F.C). Carry clear means full or empty respectively. Callers that ignore carry after a failed pop will see garbage in A — the routine does not define A on failure.

Run the checker when you want machine verification:

azm --rc warn examples/05_ring_buffer.asm

main: test sequence

The companion program:

  1. Clears ring_state through IX.
  2. Pushes $11, $22, $33, then pops three times (FIFO).
  3. Stores the last pop in pop_result — expect $33.
  4. Pushes eight more bytes to fill the ring, then attempts a ninth push with $CC.
  5. Stores push_ok = 0 if that push failed (carry clear), 1 if it incorrectly succeeded.

After halt, inspect:

Label Address Expected
pop_result $800B $33
push_ok $800C $00 (ring full)
ring_state.count $800A $08

Records inside records

When a field is itself a layout, use .field:

.type Pos
x       .byte
y       .byte
.endtype

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

POS_X .equ offset(Actor, pos.x)

Nested paths work in offset and in layout casts: <Actor>player.pos.x. Arrays inside records use bracket indices with compile-time values: offset(Scene, sprites[2].color).

Unions (.union / .endunion) share the same offset rules; the union’s size is the largest member. Chapter 4’s packed flags fit naturally as a byte or small union inside a larger record — same machinery, no new access path.


Examples

File What to verify
examples/05_ring_buffer.asm FIFO pop $33, push_ok = 0 on full ring
azm examples/05_ring_buffer.asm
azm --rc warn examples/05_ring_buffer.asm

Single-step through ring_push once with the emulator: watch head and count update via (ix + RING_HEAD) and confirm HL targets the expected cell in ring_buf.


Summary

  • .type / .endtype describe packed layout; they do not emit bytes by themselves.
  • sizeof(Type) and offset(Type, field) are compile-time constants — name them with .equ and use them in code and .ds.
  • .ds byte[8], .ds RING_CAP, .ds RingState and literal record arrays such as .ds Record[4] reserve exact byte counts.
  • IX + offset constants is the idiomatic in-record access; HL + BC handles table + runtime_index.
  • Layout casts <Type>label.field and <Type[N]>table[i].field fold constant addresses; runtime indices use explicit arithmetic.
  • A ring buffer implements a FIFO with head, tail, count and wrap — no memory shifting.
  • AZMDoc on @ routines documents success/fail conventions (here, the carry flag) as well as register roles.

Exercises

  1. Without assembling, compute sizeof(RingState), offset(RingState, tail) and offset(RingState, count) for the chapter’s three-byte layout. Write the three .equ lines.
  2. Add a flags byte to RingState after count. Which .equ lines change? Which push/pop code must change?
  3. Rewrite the ring_buf[head] address setup using DE as base and keeping the index in C. Keep the same contract on ring_push.
  4. Change RING_CAP to 16 and use and 15 in ring_advance_index instead of cp / xor. Prove on paper that head never reaches 16.
  5. Write a ring_peek routine that returns the oldest byte in A without removing it. Document ;! in, ;! out and ;! clobbers; fail with carry clear when empty.
  6. Load the address of ring_state.head into HL using a layout cast, then using ring_state + offset(RingState, head). Assemble both forms and confirm the same immediate.
  7. Reserve Event records with .type Event / code .byte / param .word / .endtype and .ds Event[4]. Write a loop that zeroes every param field using sizeof(Event) as stride.