Chapter 12 — Register Contracts

Chapter 11’s contract is the right idea: the subroutine declares what it reads, what it returns and what it destroys; the caller reads that and writes code accordingly. An ordinary comment can drift away from the code after repeated edits.

The .routine directive is AZM’s structured contract format. It marks the next non-local label as a routine entry and gives the register contract analyzer machine-checkable boundary information for every call. Informal subroutine discipline becomes something the assembler can verify.

This chapter teaches the mental model: caller liveness, callee boundaries, flags as return values, .routine boundaries, external .asmi contracts and the CLI workflow that makes register contracts part of daily work.


The bug contracts catch

Consider a caller that keeps HL live across a call:

    ld hl, table
    ld b, 8
    call find_max
    ld a, (hl)             ; BUG if find_max clobbered HL

If find_max walks HL through the table and does not restore it, HL now points past the end. The next ld a, (hl) reads the wrong byte. The assembler still accepts the program; the CPU runs it; the bug is silent.

Register contracts close that gap. A contract on find_max might say:

; find_max: scan a byte table and return the largest value
.routine in HL,B out A clobbers B,HL
find_max:

Running azm --rc warn source.asm can then report:

source.asm:6: warning: HL is live across call to find_max, but find_max may clobber H, L

The analyzer does not need to know what table means. It only needs to know: the caller had a value in HL, called something that may destroy HL, then used HL again. That is enough to flag a real bug.

The fix is caller-side: reload HL, save it before the call or stop using HL after the call:

    ld hl, table
    ld b, 8
    call find_max
    ld hl, table        ; reload — find_max clobbered HL
    ld a, (hl)

Register contracts are not linting for style. They are boundary checking at subroutine calls — turning “I thought HL was still valid” into a diagnostic with a line number.


A contract is the boundary between caller and callee

The caller asks one question about every register it still plans to use after call:

Is this register still mine?

The callee contract answers:

.routine clobbers HL

“No — HL may be different after return.”

The caller sees only the external interface: registers and flags that must be set on entry, registers and flags that carry results on exit and registers the routine destroys without restoring. Everything that happens inside the body — scratch registers, loop counters, temporary pushes — matters only if it leaks across ret.

That caller-side liveness idea is the heart of register contracts. The subroutine body can be long; the contract is short because it describes the door, not the room.


Caller and callee see different things

Internal scratch is not an out

A loop counter in B is internal:

copy_bytes:
    ld b, 4
_loop:
    ...
    djnz _loop
    ret

The caller does not read B after return. B was scratch inside the routine. You do not write out B unless the caller is supposed to use B as a result. Register contracts care whether the caller’s B was preserved, not whether B changed inside the callee.

push / pop means preserved, not out

copy_bytes:
    push bc
_loop:
    ld a, (hl)
    ld (de), a
    inc hl
    inc de
    djnz _loop
    pop bc
    ret

BC is restored before ret. The caller’s BC is intact. Correct contract:

.routine in HL,DE,B clobbers A,HL,DE

BC does not appear in clobbers because the push/pop pair preserved it. Writing out BC would wrongly suggest the caller should read BC as a return value.

Common mistake: confusing preserved with returned

push bc
...
pop bc
ret

does not mean out BC. It means BC is preserved, so it usually does not appear in the generated contract at all.

Likewise:

ld b, 4
_loop:
    ...
    djnz _loop
ret

does not mean out B unless the caller is meant to read B after return. B was an internal loop counter.

This distinction is the bug pattern behind many real projects: a tool or human sees ld b, … inside a routine and assumes B is an output. The contract should describe what the caller may rely on, not every register touched along the way.


The five contract words

Place .routine immediately before a routine entry. Its five keys are:

Key Meaning
in Registers and flags whose incoming value is meaningful to the routine
out Registers and flags that carry the returned result
maybe-out Inferred output candidates awaiting review before promotion to out
clobbers Registers and flags the routine modifies and does not restore
preserves Registers and flags explicitly restored before return (uncommon when push/pop already handled it)

A complete contract for find_max:

; find_max: scan a byte table and return the largest value
.routine in HL,B out A clobbers B,HL
find_max:
  ld a, 0
_loop:
  cp (hl)
  jr nc, _skip
  ld a, (hl)
_skip:
  inc hl
  djnz _loop
  ret

The human-readable ; line stays for prose. The .routine lines are what the analyzer parses.

Carrier lists use comma-separated names:

.routine in A,DE,HL out carry clobbers BC

Register pairs are shorthand: BC means B and C. Flags are named individually: carry, zero, sign, parity, halfCarry. Use carry for the carry flag and C for register C — both are short names; the distinction matters.

A carrier that transforms in place can appear in both in and out:

.routine in DE out DE clobbers A

That declares an intentional transformation, not an accidental clobber.


Flags are return values

AZM Book 2 uses carry for success and failure (ring_push, ring_pop and others). Flags are first-class contract carriers, not an afterthought.

Success on carry set

; try_read: read one byte into A; carry set on success
.routine in HL out A,carry clobbers BC,HL
try_read:
    ...
    scf
    ret
_empty:
    or a        ; clears carry
    ret

The human comment explains meaning (success vs empty). The contract names the carrier:

.routine out carry

Empty test on zero

; is_empty: return whether count byte is zero
.routine out zero
is_empty:
    ld a, (count)
    or a
    ret

or a sets Z when A is zero. Callers test with jr z, jr nz, ret z or call nz — those instructions are evidence the flag mattered.

Teaching point

A flag can be the entire return value. You do not need a separate error code byte when carry or zero already communicates success, failure or “found”. Document the flag in out; put semantic wording in the plain ; line above the contract:

; ring_push: append byte in A; carry set on success, carry clear when full
.routine in A,IX out carry clobbers BC,DE,HL
ring_push:

Avoid embedding flag syntax in the machine line (out F.C) when out carry is the formal carrier and the comment carries the success/failure story.

out and clobbers must not contradict

Register pairs in clobbers expand to their parts: AF means A and F together. If A or a flag is an out, do not also list that carrier inside a broad clobbers AF line — beginners read that as “return A, but destroy A.”

Rule: out describes what the caller may rely on after ret; clobbers lists everything else destroyed without restore. When A and carry are outputs, name them in out and list only the other scratch registers in clobbers:

; ring_pop: oldest byte in A; carry set on success, carry clear when empty
.routine in IX out A,carry clobbers BC,DE,HL
ring_pop:

Register contracts treat out as authoritative at the return boundary. Internal use of A or flags mid-routine does not require listing A in clobbers when the contract promises a defined A and carry on exit.


Declare routine boundaries with .routine

The .routine directive marks an explicit routine entry for register contract analysis:

.routine in HL,B out A clobbers B,HL
find_max:

AZM associates the directive with the next non-local label. Owner-local labels begin with _ and remain inside that routine’s namespace.

Failure story: ambiguous routine boundaries

check_collision:
    push bc
    ...
_loop:
    ...
_done:
    pop bc
    ret

_loop and _done resolve under check_collision, so another routine may reuse those names without a collision.

Correct shape:

.routine clobbers AF
check_collision:
    push bc
    ...
_loop:
    ...
_done:
    pop bc
    ret

The next .routine starts another routine boundary. AZM has no .endroutine; data and ordinary labels after a routine remain ordinary declarations unless another .routine marks a new callable entry.

The @ prefix has one separate job: it exports a symbol from a source unit. It does not mark a routine and has no register-contract effect:

.routine in HL,B out A clobbers B,HL
@find_max:
    ...

Callers still write call find_max; @ is declaration syntax and is not part of the symbol name. @_done: is an error because an owner-local symbol cannot be exported.


Register contract syntax reference

Place .routine immediately before the non-local entry label. Register contract modes:

Command Effect
azm --rc audit source.asm Analyze contracts without failing the build; useful while editing
azm --rc warn source.asm Print warnings but still build
azm --rc error source.asm Fail on proven register contract conflicts
azm --rc strict source.asm Fail on anything AZM cannot prove safe

Practical workflow:

azm --rc audit source.asm
azm --contracts --rc audit source.asm
azm --rc error source.asm
azm --rc strict source.asm
Flag Role
--contracts Generate or upgrade .routine directives from inference
--reg-interface Export .asmi contracts from annotated source
--interface file.asmi Import contracts for code you cannot inspect
--reg-report Advanced text report for debugging, CI evidence or large audit sessions

Typical progression: run --rc audit on legacy code, add .routine directives (or use --contracts as a draft), fix call sites, then enforce with --rc error and --rc strict. Add @ only to symbols that another source unit imports.


External code uses .asmi

ROM routines, monitor calls, BIOS entry points and Debug80 stubs have no AZM source to analyze. Register contract analysis cannot inspect their bodies. .asmi files declare their boundaries — one record per external symbol, no comment syntax:

extern MON_PRINT_CHAR
in A
clobbers A
end

extern MON_GET_KEY
out A
out zero
clobbers carry
end

Load when assembling:

azm --interface monitor.asmi --rc warn source.asm

Caller in source:

    ld a, 'A'
    call MON_PRINT_CHAR

AZM cannot see inside ROM. .asmi is how you teach the analyzer what the external routine does — the same in / out / clobbers vocabulary as .routine blocks, stored in a separate file you can share across projects (MON3, platform ROM tables, emulator integration).

If a project calls many system routines, one .asmi file holds all declarations. Update it when platform documentation changes; source files stay unchanged.

Book 2 Chapter 7 revisits .include and library layout; the external boundary story lives here in Book 1.


A worked example: annotating find_max and count_above

From Chapter 10’s subroutines:

Step 1 — identify the callable entries.

find_max:
  ld a, 0
_loop:
  cp (hl)
  jr nc, _skip
  ld a, (hl)
_skip:
  inc hl
  djnz _loop
  ret

count_above:
  push de
  ld d, 0
_loop:
  ld a, (hl)
  cp c
  jr c, _skip
  jr z, _skip
  inc d
_skip:
  inc hl
  djnz _loop
  ld a, d
  pop de
  ret

azm --rc audit source.asm reports what still needs an explicit contract while you are shaping the code.

Step 2 — add contracts from intended behavior.

; find_max: scan a byte table and return the largest value
.routine in HL,B out A clobbers B,HL
find_max:
  ...

; count_above: count bytes strictly above threshold in C
.routine in HL,B,C out A clobbers B,HL
count_above:
  push de
  ld d, 0
  ...
  pop de
  ret

D does not appear in clobbers because push/pop preserves DE for the caller. The contract describes the door: caller’s DE is intact; internal use of D is invisible.

Step 3 — verify.

azm --rc warn source.asm

If main reloads HL before each call (Chapter 10), checks pass. If main uses HL after find_max without reloading, register contracts report the conflict against clobbers HL.

Step 4 — catch a lying contract.

If find_max later uses DE internally but the contract still omits DE:

; stale contract: body now uses DE
.routine clobbers B,HL
find_max:
  ...

With --rc error, inferred effects that exceed the declared contract are flagged. Callers that relied on DE across the call had a latent bug; the stale contract hid it.


Register contract scope

Register contracts verify register and flag boundary consistency at calls. Keep these separate checks in your review:

  • Algorithm correctness (GCD, sort order, chess rules)
  • Memory aliasing (two pointers to the same buffer)
  • Stack depth or overflow
  • Interrupt safety or re-entrancy
  • Semantic meaning of values in registers (HL as string vs table vs node)

Use it where informal discipline breaks down: live registers across call, documented clobbers vs actual code and external routines described in .asmi. It turns comments into checkable promises at the boundary — AZM’s killer feature for maintainable assembly, not a replacement for thinking about the algorithm.


Summary

  • A contract is checked at the call site: caller liveness vs callee in / out / clobbers.
  • Internal scratch and push/pop preservation are not out values; preserved registers usually omit clobbers.
  • Flags (carry, zero, …) are first-class returns; put meaning in human ; lines, carriers in .routine out.
  • .routine marks a routine entry; @name: exports a symbol and has no contract meaning.
  • _name: is owner-local to the nearest preceding non-local label and is suitable for branch targets inside a routine.
  • .asmi describes ROM/monitor/external code; --interface imports it.
  • Workflow: start with --rc audit, use --contracts for source annotations, then enforce with --rc error or --rc strict.

Exercises

1. Write a contract. Given this subroutine, write the correct register contract (in, out, clobbers; use preserves only if needed):

; copy_bytes: copy B bytes from HL to DE
copy_bytes:
  push bc
_loop:
  ld a, (hl)
  ld (de), a
  inc hl
  inc de
  djnz _loop
  pop bc
  ret

Does push/pop on BC belong in clobbers or not? Why?

2. Read a diagnostic.

source.asm:18: warning: HL is live across call to find_max, but find_max may clobber H, L

find_max declares clobbers B, HL only. What does the warning mean? What should the caller change?

3. Write an external contract. BIOS_READ_SECTOR takes HL = buffer, B = sector number; returns carry clear on success, carry set on error; clobbers A, BC, DE. Write the .asmi record (use carry, not F.C).

4. Flags as return. Write ring_try_pop that returns the oldest byte in A with carry set on success and carry clear when empty. Include human ; line and .routine block. Show one caller fragment that branches on carry after call.

5. Spot the wrong contract.

; normalize: clamp A to range 0-127
.routine in A out A clobbers B
normalize:
  cp $80
  jr c, _done
  ld a, $7F
_done:
  ret

Does the body use B? What is the cost of a false clobber vs a missing one? Rewrite the contract.

6. Routine and branch labels. Rewrite check_collision using .routine before check_collision: and owner-local labels _loop and _done. Explain why another routine can reuse _loop without a duplicate-symbol error.