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 applyring_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_CAPheadandtailare each in0 .. RING_CAP - 1- the oldest byte is at
ring_buf[tail]whencount > 0 - the next free slot for push is
ring_buf[head]whencount < 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:
- Clears
ring_statethrough IX. - Pushes
$11,$22,$33, then pops three times (FIFO). - Stores the last pop in
pop_result— expect$33. - Pushes eight more bytes to fill the ring, then attempts a ninth push with
$CC. - 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/.endtypedescribe packed layout; they do not emit bytes by themselves.sizeof(Type)andoffset(Type, field)are compile-time constants — name them with.equand use them in code and.ds..ds byte[8],.ds RING_CAP,.ds RingStateand 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.fieldand<Type[N]>table[i].fieldfold 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
- Without assembling, compute
sizeof(RingState),offset(RingState, tail)andoffset(RingState, count)for the chapter’s three-byte layout. Write the three.equlines. - Add a
flagsbyte toRingStateaftercount. Which.equlines change? Which push/pop code must change? - Rewrite the
ring_buf[head]address setup using DE as base and keeping the index in C. Keep the same contract onring_push. - Change
RING_CAPto 16 and useand 15inring_advance_indexinstead ofcp/xor. Prove on paper thatheadnever reaches 16. - Write a
ring_peekroutine that returns the oldest byte in A without removing it. Document;! in,;! outand;! clobbers; fail with carry clear when empty. - Load the address of
ring_state.headinto HL using a layout cast, then usingring_state + offset(RingState, head). Assemble both forms and confirm the same immediate. - Reserve
Eventrecords with.type Event/code .byte/param .word/.endtypeand.ds Event[4]. Write a loop that zeroes everyparamfield usingsizeof(Event)as stride.