Zero page allocation

Zero page is a valuable but limited resource on the 6502. It's typically used as if it were an extended register file as it's faster to access, and is the only way to access values through a pointer. In a complicated program, hand-managing which zero page locations a routine uses as inputs, outputs or scratch space is exactly the sort of fiddly bookkeeping we would rather a tool did for us.

So Baron does it. You hand over a pool of zero-page bytes, declare named variables, and Baron works out where each one lives - packing several onto the same byte when their lifetimes never overlap, and refusing outright rather than ever emitting code it cannot prove safe. It is entirely opt-in: no ZA_POOL in your source, no allocator, no cost.

This is the usage guide. If you want to know how Baron pulls it off, the machinery lives in the internals document.

Contents

A first taste

ZA_POOL &70..&8F        ; hand Baron these 32 bytes to play with

.multiply
{
    ZA_AUTO1 m1, m2, result

    STA m1
    STX m2
    LDA #0 : STA result
    ; ... and so on
    RTS
}

As far as the rest of your program is concerned, m1, m2 and result are ordinary zero-page symbols - LDA m1 assembles to a two-byte zero-page load, just as if you had written m1 = &70 yourself. The difference is that you didn't: Baron picked the addresses for you, from the pool you reserved. Better still, if two variables are never needed at the same moment, Baron happily gives them the same byte. The -v listing shows you each choice as the assignment it became: m1 = &70 [auto].

ZA_POOL: the pool

ZA_POOL tells Baron which bytes it's allowed to use - a comma-separated list of addresses and ranges:

ZA_POOL &70..&8F                   ; a run of 32 bytes
ZA_POOL &70..&8F, &A8..&AF         ; two runs, wherever the OS leaves you gaps
ZA_POOL &70, &71, &72              ; individual bytes, if you must

There is one zero page, so there is one pool for the whole program, and it must appear before any variable that wants to use it. Only reserve bytes that are genuinely yours - Baron takes your word for it - but within that, reserve generously: an unused byte costs nothing.

ZA_AUTO: the variables

ZA_AUTO1 counter, temp, flags    ; one-byte variables
ZA_AUTO2 src, dst                ; two-byte pairs - pointers
ZA_AUTO 16, sines                ; a 16-byte table
ZA_AUTO WIDTH*2, buf             ; the count is any constant expression, 1 to 256

A variable is a normal scoped symbol: declare it inside a named block and it can be reached from outside as block.var, exactly like a label - which is how a routine publishes its inputs and outputs (see Subroutine inputs and outputs). A ZA_AUTO2's low byte is name and its high byte name+1, so things like this just work:

ZA_AUTO2 ptr
    LDA #LO(data) : STA ptr
    LDA #HI(data) : STA ptr+1
    LDY #0
    LDA (ptr),Y

Wider variables work the same way - table, table+1, up to table+count-1 - or with an index (table,X), which is allowed but warned about (the run-time index is yours to bounds-check) until you declare the index set with ZA_INDEXEDBY - then it is Baron's to check (see The rules).

The address of a variable

While your program is being assembled, a variable doesn't have an address yet - Baron only picks the addresses at the very end, once it has seen everything. Until then, the address is a placeholder. That's fine almost everywhere: as long as the code being produced doesn't change depending on the address, you can use the placeholder freely and Baron fills in the real number at the end:

    LDA #v : STA ptr        ; the address as an immediate - seeding a pointer
    LDA #0 : STA ptr+1

.handlers
    EQUW v, w               ; address tables of variables just work

vhi = v + 1                 ; aliases carry the identity: LDA vhi is a use of v

    PRINT "v lives at &", ~v   ; PRINT happens after allocation, so this is the real address

What you can't do is make the shape of the program depend on an address: a condition (IF v <> w), a count (SKIP v, ZA_AUTO v, q), a loop bound (FOR n = v..8), a section's org. Those all need a real number while Baron is still assembling - before any addresses exist - so Baron refuses them at the line: Cannot use a ZA_AUTO address here: 'v'.

An address also needs code that touches the variable. Baron works out when a variable's byte is in use from the instructions that read and write it, so a variable nothing reads or writes gets no address at all - and using its address anyway (EQUW v, LDA #v, PRINT v) is an error at each use: ZA_AUTO address used, but no instruction touches the variable: 'v'. Storage that is only ever reached through a pointer belongs at a fixed address instead.

The only arithmetic a placeholder address supports is adding or subtracting a whole number (that is how ptr+1 works); the only other operator that will touch one is ~, which formats it for PRINT. Anything else - multiply, compare, a range - is a type error.

What you can rely on

Baron doesn't guess at any of this: it reads your instructions and traces what actually happens to each variable. These are the promises it makes:

Subroutine inputs and outputs

You can declare a routine's inputs and outputs as variables in its own scope, and let callers reach them by their fully scoped (dot separated) path:

ZA_POOL &70..&8F            ; In reality only uses ONE byte for the lot

.start
    LDA #5 : STA scale.input
    JSR scale
    LDA scale.result : STA offset.input    ; one stage's result feeds the next
    JSR offset
    LDA offset.result                      ; A = (5 * 2) + 7
    RTS

.scale                      ; scale.input * 2 -> scale.result
{
    ZA_AUTO1 input
    ZA_AUTO1 result
    LDA input : ASL A : STA result
    RTS
}

.offset                     ; offset.input + 7 -> offset.result
{
    ZA_AUTO1 input
    ZA_AUTO1 result
    LDA input : CLC : ADC #7 : STA result
    RTS
}

Four variables, and the whole relay runs in a single byte - the -v listing shows every one of them landing on &70. The value simply flows through it: argument in, doubled in place, relayed, seven added, result out. That's the tightest packing possible, and it comes from three guarantees:

We can show the final point by contriving a contrary scenario. Let's change offset so the store is conditional, and watch the allocation change with it:

.offset                     ; offset.input + 7 -> offset.result, left alone when the sum is zero
{
    ZA_AUTO1 input
    ZA_AUTO1 result
    LDA input : CLC : ADC #7 : BEQ @+
    STA result : .@
    RTS
}

Now the caller's LDA offset.result may see whatever the byte held before the call, so result must genuinely be preserved across the subroutine - and Baron allocates accordingly: everything else still shares &70, while this offset.result alone is kept clear of the whole journey on &71 (the pool needs two bytes).

Nothing was declared; the routine's own instructions are the specification.

Wasteful code

When using the allocator, it is worth glancing at the verbose listing now and then to see what was really produced. In the example above, the relay line assembles to a load and a store of the same byte:

  0007  A5 70                       LDA scale.result
  0009  85 70                       STA offset.input

The store is wasted - harmless, but six cycles of nothing. You might be tempted to guard the relay with IF scale.result <> offset.input - but an IF on a ZA_AUTO variable is an error, and the assembly fails:

relay.6502:6:7: error: Incompatible types

A conditional can't depend on an allocated address, because the addresses are only chosen once the whole assembly is finished - at IF time there is nothing to compare. Baron refuses rather than guessing, and the same applies to every construct that would let the program's shape depend on an address: IF var alone (Cannot use a ZA_AUTO address here: 'v'), SKIP var, FOR n = var..8, org = var and friends - see The address of a variable.

If you want the copy gone, say so by naming: when two stages should hand a value over in place, give that value one name they share -

ZA_AUTO1 pipe               ; scale's result IS offset's input

    JSR scale
    JSR offset              ; no relay - the value is already where offset looks
    RTS

.scale  { ZA_AUTO1 xin : LDA xin : ASL A : STA pipe : RTS }
.offset { ZA_AUTO1 res : LDA pipe : CLC : ADC #7 : STA res : RTS }

They still share a single location, and the relay instructions are gone entirely. The scoped symbol paths buy decoupling; the shared variable buys fusion. Choose your weapon!

Inline data after a JSR

A classic 6502 idiom passes data to a subroutine inline, right after the call:

    JSR pstring
    EQUS "HELLO", 13, 0
    LDA #0                  ; execution resumes here

pstring pulls the return address off the stack, walks the text to its terminator, and resumes the caller past it - either by pushing the adjusted address back and RTSing, or by jumping straight there.

Baron understands the shape: data emits no instructions, so the allocator's view of the caller simply steps from the JSR to the next instruction - exactly where a data-consuming callee resumes. A callee that returns with a munged RTS therefore needs no annotation at all: variables live across the call survive the excursion, and the callee's outputs flow back, just as for an ordinary call.

A callee that jumps back instead ends in computed flow, which the allocator refuses until you name it - and the name for "this jump goes back to whoever called me" is ZA_RETURN:

.pstring
{
    ZA_AUTO2 ptr
    PLA : STA ptr : PLA : STA ptr+1
    ...                     ; walk the text; advance ptr past the terminator
    JMP (ptr)
    ZA_RETURN               ; this jump IS our return
}

When the callee resumes the caller somewhere other than just past the data - a skip table, an error path - ZA_RETURNTO on the caller side names the real resumption point(s). And if a JSR genuinely never comes back at all, say so with ZA_UNREACHABLE right after it, and nothing downstream is held live on its account. All three are described with the other annotations below.

The BIT-skip trick

Another old favourite: a lone BIT opcode whose operand fetch swallows the next instruction, so two code paths share their tail without a jump:

    BEQ addsecond
    ADC first
    BITABS                  ; &2C - the BIT swallows the next two bytes
.addsecond
    ADC second              ; runs only when branched to; fall-through hops straight over it
    STA total               ; both paths land here

Spell the skip byte BITABS (emits &2C, a BIT abs - swallows the next two bytes) or BITZP (emits &24, a BIT zp - swallows one) rather than a raw EQUB, and the allocator models the trick exactly: the swallowed instruction touches its variables only on the branch-taken path, and the fall-through path resumes past it, executing nothing but a harmless flag-trashing read.

With a raw EQUB &2C the byte is invisible, and the analysis assumes the fall-through runs the swallowed instruction too. Reads merely pack less tightly, but a swallowed store would be credited to a path that never performs it - exactly the kind of claim the allocator must not make. Use the keywords; they also read better.

What you swallow is yours to choose - a 2-byte instruction under BITABS, two 1-byte instructions, one 1-byte instruction under BITZP, even two bytes of inline data. The one shape Baron refuses is a resume point that lands inside an instruction (say a 3-byte LDA addr right after BITABS): the two streams then interleave mid-instruction, past what the analysis can express.

Annotations

Some facts Baron cannot see from the instruction stream, and rather than guess it stops and asks. An annotation is your promise to the allocator.

ZA_UNREACHABLE

Placed after a branch you know is always taken, telling Baron not to bother with the code path that never happens:

    ADC #10
    BCC in_range        ; carry is always clear here, honest
    ZA_UNREACHABLE      ; This is actually some pretty neat documentation, zp allocator or not

It emits nothing and changes nothing; it just lets Baron pack tighter. It works after a JSR too: sited right after a call that never returns, it severs the continuation, so nothing is held live for code that never runs on that path.

ZA_CANCALL

Placed after a JSR whose real target is computed (self-modified, or dispatched through a table), naming every routine it can reach:

    JSR dispatch                ; address patched at runtime
    ZA_CANCALL handler_a, handler_b, handler_c

Do annotate a self-modified call even when its placeholder operand looks like an innocent constant - the constant reads as a harmless OS call, which is not what the patched JSR does at runtime. A destination may be external (ZA_CANCALL handler_a, &FFEE): that arm contributes nothing. Targets may also arrive as a list-valued symbol - subs = {handler_a, handler_b} then ZA_CANCALL subs - flattened exactly like EQUB data.

Name your own code by its label: the label, an alias of it, a list holding it, or label+3. A plain number that lands on code you assembled is refused, because it can't say which bank it means. Ranges count as plain numbers here (hB..<hB+1 names addresses, not labels).

ZA_CANJUMP

Placed after a computed or indirect JMP through a vector or table you assembled, naming every landing site:

    JMP (vector)                ; one of the mode handlers - Baron cannot see which
    ZA_CANJUMP mode_draw, mode_erase, mode_flip

External arms work here too: JMP (myvec) : ZA_CANJUMP &FFEE says the vector may hold an OS address, and that arm is as clean an exit as an RTS. (A jump through a constant OS vector needs no annotation at all.)

The declared targets replace whatever the literal operand said - a self-modified JMP's placeholder wires no edge of its own - and list-valued symbols flatten here just as for ZA_CANCALL, so a jump table's target list can live in one word. A self-modified branch is annotated the same way: BNE placeholder : ZA_CANJUMP real_a, real_b replaces the taken edge with the declared arms while the ordinary fall-through stays.

ZA_CANJUMP also marks the RTS-dispatch trick - pushing a target address onto the stack and jumping to it with an RTS. An unmarked RTS looks exactly like a real return, and Baron trusts it as one; the annotation tells it this one is really a jump, and the analysis follows control to the declared targets like any other dispatch:

    LDA #HI(handler-1) : PHA
    LDA #LO(handler-1) : PHA
    RTS                         ; not a return: it "returns" into handler
    ZA_CANJUMP handler

The PHP : RTI flavour (address pushed unadjusted) is annotated the same way.

For ZA_CANCALL and ZA_CANJUMP alike, list every destination - if you omit something by mistake, you may see your zp getting clobbered unexpectedly.

ZA_RETURN

Placed after a jump (or branch) whose destination is simply whoever called this routine - the inline-data trick's exit, where a routine pops its return address, consumes the data following the call, and jumps past it:

    JMP (ptr)                   ; ptr points just past the inline data
    ZA_RETURN                   ; this jump IS our return

No target list - the destination is "back to our caller", and Baron routes liveness home to every call site exactly as it does for an RTS.

It also tames a self-modified direct JMP whose placeholder operand happens to name real code (the placeholder edge is ignored, as with ZA_CANJUMP). On a branch it declares a conditional return - the not-taken path carries on as usual. And it composes with ZA_CANJUMP on the same jump, for a dispatch that may instead hand straight back.

ZA_RETURNTO

The caller-side counterpart, for when a data-consuming callee resumes you somewhere other than just past the data:

    JSR emit : ZA_RETURNTO done
    EQUS "payload", 0           ; consumed by emit
    NOP                         ; never executed - emit resumes at .done
.done

It takes a target list like ZA_CANCALL (list-valued symbols flatten the same way) and REPLACES the call's normal continuation.

You do not need it for the common shape - a callee that resumes just past the data resumes at the next instruction, which is what Baron assumes anyway. A declared target that is not the start of an assembled instruction draws a warning: it is almost always a mistyped label, or an address inside the data itself.

ZA_DISCARD

Tells Baron a variable's current value is finished with: everything read later comes from writes after this point. You need it when a variable is rebuilt through indexed stores, because STA arr,X never proves which byte it wrote:

    LDX #0
.fill
    LDA (src),Y : STA arr,X     ; rebuilds all of arr - but which byte? Baron can't tell
    INX : CPX #4 : BNE fill
    LDA arr+0 : ORA arr+1       ; so these reads look like they might want stale bytes

With no provable write anywhere, Baron assumes the old value might still be wanted, keeps arr alive back through the routine's entry and around whatever loop reaches it, and walls its bytes off from everything. One line fixes it - place it where the old value stops mattering, before the rebuild:

    ZA_DISCARD arr              ; the old value is dead; the fill loop makes a new one

It emits nothing, takes a comma list, and wants whole variables (ZA_DISCARD arr+1 is refused). TRUSTED like the others: if something does read the old value past a ZA_DISCARD, that byte may already belong to someone else.

Inside a subroutine it also tells callers their copy dies at the JSR, just as a real full rewrite would. (In the spritescale demo, two ZA_DISCARD lines freed 17 bytes of zero page at zero runtime cost - the two plotters' slot arrays fold onto each other.)

Where a marker sits

One placement rule, shared between ZA_DISCARD and ZA_WIPE: a marker belongs to the path it is written on, and a label is the pivot. A marker emits no bytes, so its address is the next instruction's - but Baron also remembers which side of a label you wrote it on, and that decides which path carries the promise:

    ZA_DISCARD arr      ; ABOVE the label: dead on the way INTO the loop -
.fill                   ; the back edge re-entering at .fill never passes it
    ...
    BNE fill
.fill
    ZA_DISCARD arr      ; BELOW the label: "at this address, however you got
    ...                 ; here" - re-asserted every time around the loop
    BNE fill

The same choice applies at a branch join: above the label, the promise stays on the arm you wrote it in; below the label, it covers every arm arriving there. Write whichever you mean.

The one exception is a marker just past an RTS/JMP: the path it sits on is dead, so it attaches to whatever arrives at that address - which is exactly what the RTS-dispatch idiom (RTS : ZA_DISCARD v : ZA_CANJUMP target) wants.

ZA_WIPE

ZA_DISCARD's mirror image, for the conventional boot-time zero-page sweep. The wipe loop stores through a literal base, so Baron cannot attribute it to any variable - it is exactly the "stray pointer aimed into the pool" the bargain warns about, except this one is deliberate:

    LDX #0
    TXA
.wiploop
    STA &00,X
    INX : CPX #&90 : BCC wiploop
    ZA_WIPE                     ; the loop above swept the whole pool - and says so

Placed directly after the loop, it promises that by this point every pool byte has been freshly written. That does three things at once - it declares the wipe a legitimate initialiser (a ZA_ENTRY routine may now rely on wiped-to-zero values without drawing the input warning), it ends every earlier value's live range, and it quiets the opt-in fixed-address warning for the sweep's own stores. The blessing reaches back through the loop but stops at a label or any jump, call or return, so a stray pool store elsewhere still warns.

Put it after the sweep, and before anything reads a variable: values count as initialised from the marker onwards, not from the loop - a wipe written above the loop would vouch for values that have not been written yet.

TRUSTED like ZA_DISCARD, only a bigger promise: nothing survives it, so a value you did want across the wipe is already lost.

The placement rule applies here too, and helpfully: a ZA_WIPE ending a branch's arm stays IN that arm - the skipped path genuinely does not wipe, and Baron plans accordingly. Only a wipe written below a label claims every path through it, which for a wipe means "however you got here, everything was just swept" - true only if every arm really does.

ZA_INDEXEDBY

An indexed access (table,X, table,Y, (table,X)) is normally trusted: Baron cannot know what the index register holds at run time, so keeping it in range is yours - and Baron says so with a default-level warning at every undeclared site. ZA_INDEXEDBY declares the values the register can hold at the access just before it - a declared access is checked, not trusted, and assembles in silence:

ZA_AUTO 8, frames
    LDX #7
.loop
    LDA frames,X : ZA_INDEXEDBY 0..7    ; checked: every index stays inside the 8 bytes
    DEX : BPL loop

The operand flattens like EQUB data - a range (0..7), a list ({0, 2, 4}), a comma run, or a symbol bound to any of those - and each value must be 0-255 (an index register holds a byte). Baron checks that the constant base plus every declared index, smallest to largest, stays inside the variable; a (table,X) reads a two-byte pair at the indexed offset, so the pair's second byte must fit too:

ZA_AUTO 4, quad
    LDA quad,X : ZA_INDEXEDBY 0..3      ; fine - the whole variable
    LDA quad,X : ZA_INDEXEDBY 0..4      ; refused - index 4 reaches past the end
    LDA quad+2,X : ZA_INDEXEDBY 0..1    ; fine - base 2 leaves room for 0..1
    LDA (quad,X) : ZA_INDEXEDBY 0..2    ; fine - the pair at offset 2 just fits

The base may even sit below the variable, when the index counts from somewhere above zero:

ZA_AUTO 4, rows
    LDA rows-4,X : ZA_INDEXEDBY 4..7    ; fine - X from 4 lands on rows+0 to rows+3
    LDA rows-4,X : ZA_INDEXEDBY 2..7    ; refused - index 2 reaches rows-2
    LDA rows-4,X                        ; refused - nothing says X makes up the difference

The operand byte is then rows-4 itself, which must still be a zero-page address, so Baron places rows no lower than &04.

An in-range declaration also quiets the unchecked-index warning for that access - there is nothing left to warn about. TRUSTED like every annotation, but a smaller promise than most: only that the register stays inside the declared set at that instruction; the geometry is checked for you.

One thing it does not do: discard. STA arr,X : ZA_INDEXEDBY 0..7 bounds where the store can land, but a single execution still writes just one, unknowable byte - it proves nothing about the other seven. An array rebuilt through indexed stores still wants its ZA_DISCARD where the old value dies; the two annotations answer different questions - where can an access land versus when is a value dead - and compose happily on the same store.

ZA_ENTRY

Put this at the top of any routine that the outside world calls. A routine that only BASIC ever CALLs is referenced by nothing in the program, so to Baron it looks like dead code:

.blit
    ZA_ENTRY                    ; nothing here calls this - BASIC does
    LDA xpos
    ...
    RTS

The marker takes no operand; it just remembers the address where it stands, so either side of the label works. Normally Baron assumes execution enters at the top of each section. Write one ZA_ENTRY and it stops guessing: your markers are now the complete list of ways in, so mark every routine the outside calls - the main one included. Anything touching a ZA_AUTO variable that none of them can reach gets a warning: dead code, or a routine you forgot to mark.

One thing ZA_ENTRY does not do: keep BASIC's pokes and peeks safe. If BASIC does ?&70=X% : CALL blit, then &70 had better not be auto-allocated - the allocator would cheerfully move it next build. Variables you share with the outside get a fixed home:

xpos = &70                      ; BASIC pokes here, so it must stay put - not a ZA_AUTO

Baron watches for the mistake: a ZA_ENTRY routine that reads a ZA_AUTO variable before writing it is expecting someone to have set the value - and the only someone is the outside caller, who cannot know an auto-allocated address. That draws a warning naming the variable (ZA_AUTO input to a ZA_ENTRY routine). The fix is one of two: give the variable a fixed home as above, or initialise it in the routine before the first read.

ZA_INTERRUPT

The same marker, for interrupt handlers. These need more than a "way in", because an interrupt fires between any two instructions. Say the handler counts frames:

ZA_AUTO1 vsync

.irq
    ZA_INTERRUPT
    LDA #&40 : STA &FE4D
    INC vsync
    LDA &FC
    RTI

Without the marker, vsync looks dead nearly everywhere - only the handler and one wait loop touch it - so some innocent temporary gets packed onto its byte, and the next interrupt increments your temporary. With it, Baron keeps the handler's world apart: vsync gets a byte all to itself, and nothing the handler touches can land on a byte the main program might be using when it fires. The same goes the other way: a result the handler writes for the main program - even one it only writes on some interrupts - gets a byte of its own. Temporaries inside the handler still share with each other as usual, and two marked handlers are kept apart from each other too (an NMI can land mid-IRQ).

Two limits worth knowing: a multi-byte variable shared with a handler can still be caught half-updated (keep shared state to single bytes, or bring your own interlock), and an RTS-dispatch inside a handler is as invisible here as anywhere - ZA_CANJUMP it.

The rules

What the allocator will not accept, and what it trusts you with:

Tips for tight packing

A byte becomes reusable the moment a variable is finished with, so the trick is to be finished with things as early as possible:

If you run out of bytes, Baron names the variable it could not place - usually the fix is a bigger ZA_POOL, or noticing that something is being kept around longer than it really needs to be.

Errors and warnings

Every error here is a refusal - Baron will not emit code it cannot vouch for. Fix it, annotate it, or fall back to a hand-placed address.

Message What happened
ZA_POOL address outside the zero page The pool can only contain bytes &00-&FF.
ZA_AUTO needs a prior ZA_POOL A variable with no pool reserved. Add a ZA_POOL first.
ZA_AUTO count must be 1 to 256 A generic ZA_AUTO n with a count outside 1-256.
Cannot use a ZA_AUTO address here: '...' A variable's address used somewhere that needs a real number during assembly - a condition, a count, an org, a ZA_POOL byte, an annotation target. See The address of a variable.
ZA_AUTO address used, but no instruction touches the variable: '...' The variable's address appears in data, an immediate, a PRINT or a section attribute, but no instruction reads or writes the variable, so Baron cannot tell when it is in use and gives it no address. Touch it in code, or give it a fixed address.
ZA_AUTO variable cannot be named 'A' The accumulator clash. Rename it.
No free zero-page byte for ZA_AUTO variable: '...' More variables needed at once than the pool has bytes. Reserve more, or shorten a lifetime.
ZA_AUTO variable live across an unanalysable JSR (annotate with ZA_CANCALL) A variable held across a call whose destination Baron cannot follow.
Computed jump reaches unknown code (annotate with ZA_CANJUMP) A jump table or indirect JMP the analysis cannot follow.
Annotation target names our own code by address (use its label) A ZA_CANCALL, ZA_CANJUMP or ZA_RETURNTO target is a plain number that lands on code you assembled. A number can't say which bank it means; the label can.
ZA_AUTO variable freshly written and held live across recursion: '...' A per-level value in a call cycle - one byte cannot hold a value per level. Every culprit is named.
ZA_AUTO1 dereferenced as a pointer (declare it ZA_AUTO2) (var),Y on a one-byte variable.
Access outside ZA_AUTO variable A var+n offset outside the declared width, or a var-n below it. Widen it or fix the offset.
Indexed access starts below ZA_AUTO variable (declare its indices with ZA_INDEXEDBY): '...' An indexed access like var-4,X whose base sits below the variable, with no ZA_INDEXEDBY to say which indices bring it back inside. Declare them.
ZA_DISCARD needs a whole ZA_AUTO variable: '...' The operand was a number, a fixed address, or a var+n slice. Name a ZA_AUTO variable, whole.
ZA_INDEXEDBY must follow an indexed ZA_AUTO access The annotation describes the access before it; here the previous instruction was not an indexed access to a ZA_AUTO variable (or there was none). Put it right after the var,X / var,Y / (var,X) it bounds.
Declared index reaches outside ZA_AUTO variable: '...' The constant base plus the largest ZA_INDEXEDBY index (plus a pair's second byte, for (var,X)) lands past the end of the variable, or the base plus the smallest lands below it. Widen it, or shrink the declared set.
ZA_ENTRY/ZA_INTERRUPT does not mark an instruction The marker sits on data, or after the last instruction of its section. Move it to the top of its routine.
BITABS resumes in the middle of an instruction The instruction after a BITABS/BITZP runs past the skip's resume point (a 3-byte instruction under a BITABS, say), so the two streams interleave mid-instruction. Swallow something that fits.

And six warnings (the default level shows in every run; opt-in ones need --warn 2):

Message Level What happened
ZA_RETURNTO target does not begin an assembled instruction default The declared resumption point matches no code Baron assembled - usually a mistyped label, or an address inside the inline data. That arm gets no edge; if it genuinely names an OS address, the warning is yours to wave through.
Unused ZA_AUTO variable: '...' default No instruction touches it, so it gets no address and no definition, as if the declaration were not there. Use it or remove it. (Using its address anyway is the error above.)
Unchecked indexed access into ZA_AUTO variable: '...' default An indexed access (var,X, var,Y, (var,X)) - allowed, but the run-time index is yours to keep in range. Declare the index set with ZA_INDEXEDBY and the access is checked instead (no warning).
Store into the ZA_POOL at a fixed address: '...' opt-in A store whose literal address or indexed base lies inside the pool without naming a ZA_AUTO - it may be quietly stomping an allocated variable. A deliberate whole-pool wipe is blessed with ZA_WIPE.
ZA_AUTO used in code unreachable from any entry (missing ZA_ENTRY/ZA_INTERRUPT, or dead code) default Nothing can reach this code from any entry. Usually a handler or a BASIC-called routine missing its marker; sometimes dead code; occasionally a routine behind a computed call that wants a ZA_CANCALL.
ZA_AUTO input to a ZA_ENTRY routine: '...' (external callers cannot know its address) default A ZA_ENTRY routine reads the variable and nothing in the program could have written it first - the value can only come from the caller, and an outside caller cannot know an auto-allocated address. Give the interface a fixed home (see ZA_ENTRY above), or initialise the variable before the first read. A write on any path silences it - a guarded init counts as supplying the value, and the correlations that make such code correct are yours to uphold.