The Baron guide

Baron is a 6502 cross-assembler for the BBC Micro. You feed it assembly source, and it hands you back binaries, with optional .inf sidecars, or a ready-to-boot DFS disc image. It is the successor to BeebAsm, rebuilt from the ground up: a proper multi-pass assembler with a rich expression language, lexical scoping, macros, pure functions - and a register allocator for your zero page, which we are rather proud of.

This guide walks through the language a feature at a time, teaching by example. The dry lists of every switch, keyword and operator live in the reference.

Contents

Coming from BeebAsm

If you know BeebAsm, you already know most of Baron - the mnemonics, the .label syntax, EQUB, & hex, { } scopes and colon-separated statements all carry straight over. The differences are where Baron comes into its own:

A few BeebAsm conveniences are spelled differently here: MAPCHAR is a one-line user function (the recipe is in Strings and character codes), and COPYBLOCK is now better done with a nested, rephased section (see Sections).

A first program

Let us print something. Save this as hello.6502:

oswrch = &FFEE                  ; the OS character-out routine

SECTION Main, org = &2000, filename = "HELLO"

.start
    LDX #0
.loop
    LDA message,X
    BEQ done
    JSR oswrch
    INX
    BNE loop
.done
    RTS

.message
    EQUS "Hello, owl fans!", 13, 0

ENDSECTION

Then assemble it:

$ baron -p . hello.6502

Success is silent, and a file HELLO appears in the current directory: naming the file in the section is what asked for it, and -p says where the raw binaries should land (. being here). Without -p or -o Baron assembles and reports as usual but writes nothing, and says as much.

More usefully, we can go straight to a disc image:

First create boot.6502:

SECTION Boot, filename = "!BOOT", exec = &FFFFFFFF
EQUS "*RUN HELLO", 13
ENDSECTION

Then assemble it:

$ baron -o hello.ssd --title HELLO --opt 3 boot.6502 hello.6502

That builds a DFS .ssd with the two files on it, titled HELLO, set to *EXEC !BOOT. Load it into your favourite emulator, hit Shift-Break, and there we are.

While we are here, try baron -v -p . hello.6502 - the listing shows every byte against its source line, with each label's address alongside it:

  2000  .start
  2000  A2 00                       LDX #0
  2002  .loop
  2002  BD 0E 20                    LDA message,X
  2005  F0 06                       BEQ done
  ...

A grep for a label answers with its address; and an assignment lists as what it evaluated to, with the source expression bracketed after it when they differ (PLAY_R7 = 28 [34 - 4 - 2]), so computed constants show their values too. For a machine-readable version of all this, see the symbol dump below.

-v keeps long things short: an emission past eight bytes (EQUS, INCBIN, SKIP...) truncates its dump with ..., and a list value in an assignment cuts off after eight elements. -vv is the uncut edition - every emitted byte, eight per line with the address running down the margin, and list values rendered whole:

  000A  41 42 43 44 45 46 47 48     incbin "blob.bin"
  0012  49 4A 4B 4C 4D 4E 4F 50
  001A  51 52

The shape of a line

Here are the basics:

Labels and scopes

.name defines a label at the current address. Braces open a scope, and a label immediately before the brace names it:

.reset
{
    .loop               ; this loop is private to reset...
    DEX
    BNE loop
    RTS
}

.wipe
{
    LDA #0
    .nonzero
    {
      .loop             ; ...so this one does not collide with it
      STA &3000,X
      BNE loop
    }
}

From outside, a named scope's symbols are reachable by their dotted path: JSR wipe.nonzero. This is good for when you want to provide additional entry points to a subroutine, or access a scoped symbol. Anonymous { } scopes work too, and are genuinely private - there is no path to name them by.

Braces also count as statement separators, so a compact scope fits on one line with no extra colons:

    LDX #8 {.loop STA &70,X : DEX : BNE loop } RTS

(One thing braces are not is an IF body: IF flag { ... } parses, but the braces open a real scope - its labels stay private, unlike a plain IF branch - and the ENDIF is still required.)

For tiny hops there are local labels: .@ binds an anonymous label, and @- / @+ refer to the nearest one behind / ahead:

    LDA &FE4F : BPL @+      ; skip forward to the nearest .@
    INC speed+1
.@

Locals never leak out of their scope, so use them freely.

Sections

A section is a named block of output: where the code assembles (org), and how it is saved (filename, load, exec). Attributes are name = expr pairs on the SECTION line:

SECTION Code, org = &1100, filename = "CODE", exec = start
    ; ... the program ...
ENDSECTION

Code outside any section lands in a nameless default section starting at address 0. That is fine for scratch and experiments, but it can never be saved - anything you want out of the assembler needs a named section with a filename.

A section written at the top level stands alone: it takes no room in the default section, whose address only follows its own bytes. So .here below is 0, not 2:

SECTION a, org = &2000
    EQUB 1, 2
ENDSECTION
.here

Relocation workflow is achieved with nested sections. Nest the code at its runtime address inside the loadable section, and it is carried inside the file while its labels point where it will run; a label after the ENDSECTION measures its size, and the copy-down stub follows in place:

SECTION Loader, org = &1200, filename = "GAME", exec = entry
.payload
    SECTION Code, org = &400    ; assembled for &400, carried inside GAME
    .start
        ; ... the program ...
    ENDSECTION
.entry
    ; ... copy `payload` down to &400 and jump to it ...
    LDX #HI(entry-payload+&FF)
    LDY #0
    .loop
    LDA payload,X : STA &400,X
    INY : BNE loop
    INC loop+2 : INC loop+5
    DEX : BNE loop
    JMP start
ENDSECTION

Labels around the child live in the loader's address space (payload is where the bytes sit in the file's memory image, entry - payload their length), while labels inside it live in the child's (start = &400) - which is exactly what the stub needs on each side of the copy.

Reopening sections

Writing SECTION name again for a section that has already closed writes another piece of it. The piece carries on exactly where the last one stopped, and its bytes join the end of the section's - wherever the piece happens to be written. They belong to the section being reopened, not to whatever encloses the reopening.

That lets each routine keep its data right beside it, while the data still comes out as one file:

SECTION tables, org = &5000, filename = "TABLES"
ENDSECTION

SECTION code, org = &1900, filename = "CODE"
.draw
{
    SECTION tables
    .sintab EQUB 0, 25, 49, 71, 90, 106, 117, 125
    ENDSECTION

    LDA sintab,X
    RTS
}

.plot
{
    SECTION tables
    .masks  EQUB &80, &40, &20, &10, &08, &04, &02, &01
    ENDSECTION

    LDA masks,X
    RTS
}
ENDSECTION

draw.sintab lands at &5000 and plot.masks at &5008. TABLES holds all sixteen bytes and CODE only the code.

The rules:

Virtual sections

virtual = TRUE makes a section that hands out addresses but keeps no bytes. Everything in it - SKIP, EQUB, even instructions - moves its address on and lands nowhere.

That makes it a pool of workspace, and reopening lets each routine claim its variables right where it uses them:

SECTION workspace, org = &400, guard = &800, virtual = TRUE
ENDSECTION

SECTION Code, org = &1900, filename = "CODE"
.draw
{
    SECTION workspace           ; carries on from the last claim
    .xpos   SKIP 1
    .buffer SKIP 32
    ENDSECTION

    LDX xpos
    LDA buffer,X
    RTS
}

.sound
{
    SECTION workspace
    .freq   EQUW 0              ; EQUW 0 says "two bytes" as well as SKIP 2 does
    ENDSECTION

    LDA freq
    RTS
}
ENDSECTION

draw.xpos lands at &400, draw.buffer at &401 and sound.freq at &421, while CODE holds only the code. A section opens no scope, so each label belongs to the routine that claims it, reachable from outside by its dotted path like any other scoped name.

The rules:

The same idea gives plain zero-page bytes when you want them never shared (org = &70, guard = &90), and with org = 0 it lays out the field offsets of a structure.

Memory maps

Inside a virtual section, the sections without an org of their own are laid end to end. A real section in there keeps its own bytes, and can be saved; the virtual parent just steps past them. So separately saved files can follow one another in memory without chaining each org to the end of the last:

SECTION memmap, org = &1900, virtual = TRUE
    SECTION code, filename = "CODE"
        ; ... the program ...          ; at &1900
    ENDSECTION
    SECTION data, filename = "DATA"
        ; ... the tables ...           ; straight after the code
    ENDSECTION
.free                                  ; the first unused byte
ENDSECTION

A virtual section inside the map without an org reserves its room in the same way - a group of buffers, say. One with its own org is placed elsewhere and takes no room in the map, and that is what lets it be reopened later. The same goes for a real section with its own org.

The code in code and data is real, so the zero-page allocator analyses it like any other.

Includes

INCLUDE "file.6502" splices another source file in textually (paths resolve relative to the including file), and INCBIN "file.dat" splices a binary file's bytes into the output.

Inline BASIC

BASIC ... ENDBASIC drops a BBC BASIC program straight into the output, tokenised byte-for-byte as BASIC 4 itself would store it - handy for a loader that sets the mode and runs the main binary:

SECTION loader, org=&1900, filename="Loader"
BASIC
   10REM My loader
   20MODE 7
   30*RUN MAIN
ENDBASIC
ENDSECTION

Each line starts with a decimal line number (0 to 32767) and runs to the end of the physical line. The whole line belongs to BASIC: : separates BASIC statements there and ; is a print separator, not a Baron comment. The tokeniser is the ROM's own algorithm, quirks included - keywords must be uppercase (print is a variable name, exactly as it would be on the Beeb), abbreviations like P. expand in the ROM's order, line numbers after GOTO and friends get the compact three-byte encoding, and REM, DATA, strings and * commands pass through untouched. ENDBASIC finishes the program with its &0D &FF terminator, so the emitted bytes, loaded at PAGE, are a complete program ready to RUN.

Blank lines and Baron comment lines may sit between BASIC lines; anything else inside the block is an error. Lines are emitted exactly in the order written - Baron does not sort them, so keep the numbers ascending if you want GOTO and LIST to behave - and a line whose tokenised record would pass 255 bytes is refused.

Splicing bytes into a line

Some BASIC lines hold bytes nobody can type: teletext colour codes in a REM, or a VDU 21 hidden inside a string. A { at the very end of a BASIC line drops into Baron, and a } picks the same line back up:

TT_GREEN  = &82
TT_YELLOW = &83

BASIC
1430REM"{
    EQUB &7F, TT_GREEN
}Hello,{
    EQUB TT_YELLOW
}Soni
ENDBASIC

That is still one BASIC line: 0D 05 96 13 F4 22 7F 82 "Hello," 83 "Soni". The spliced bytes go in exactly as emitted, never tokenised, and the line's length byte counts them along with everything else. The 255-byte limit is for the whole line, splices included.

The tokeniser picks up where it left off, as if the splice weren't there: inside a string we stay in the string, and after REM, DATA or a * command the rest of the line stays raw. A splice does always end a token, though, so PRI{ ... }NT is two variable names, never PRINT.

Splice rules

That last rule makes machine code in a REM easy:

BASIC
10REM{
.beep
    LDA #7 : JMP &FFEE
}
20CALL PAGE+5
ENDBASIC

The routine starts five bytes into the program - after the line's four-byte header and the REM token - and the assembly can use beep wherever it needs the address.

A splice must be closed. If a numbered line, ENDBASIC or the end of the file comes before the }, that's an error, with a note pointing back at the { that opened the splice. That catches a REM which really does end in a brace; to write one of those, splice it in: end the line with {, then EQUS "{", then }.

Expressions

Anywhere Baron wants a value, you get the full expression language: the usual arithmetic (+ - * / ^), integer DIV and MOD, shifts << >>, AND OR EOR NOT(), comparisons, and a ton of functions (SIN, SQRT, LO, HI, ...) - the whole list is in the reference.

Comparisons yield booleans (TRUE / FALSE), and AND, OR, EOR and NOT() are overloaded on what you hand them: two booleans get the logical operation (NOT(TRUE) is FALSE, a < b AND c < d reads exactly as it looks), two numbers the bitwise 32-bit one (12 AND 10 is 8), and a mixed pair is refused rather than guessed at. A boolean quietly coerces to 1 / 0 anywhere a number is wanted - (x > 5) * 10, EQUB flag.

Note, this is different to BeebAsm, which represented TRUE as -1, not 1. The command-line switch --beebasm-true can be used for BeebAsm compatibility, to set TRUE to -1.

LDA #LO(screenbase)             ; low byte - or the 6502-style  LDA #<screenbase
LDA #HI(312*64-2)               ; expressions anywhere an operand goes

* (or its BBC spelling P%) reads the current assembly address:

.spin  JMP *                    ; jump-to-self

Where it gets interesting is lists and ranges, which get the next three sections to themselves - they are the most powerful part of the language, and the part worth slowing down for.

Lists and ranges

A list is values in braces - any values, nested however you like:

{1, 2, 3}
{"one", 2, {3, 4}}              ; mixed types and nesting are fine

Lists nest, so a list of equal-length lists behaves as a 2D table - rows of columns. SHAPE tells you the axis lengths, outermost first, and RANK how many axes there are:

SHAPE({1, 2, 3})                ; {3}
SHAPE({{1, 2, 3}, {4, 5, 6}})   ; {2, 3} - two rows of three
RANK({{1, 2, 3}, {4, 5, 6}})    ; 2
SHAPE(5)                        ; {} - a scalar has no axes
SHAPE({})                       ; {0} - one axis, nothing on it
SHAPE(0..255)                   ; {256} - a range is a rank-1 list
SHAPE({{1, 2}, {3, 4, 5}})      ; {2} - ragged rows agree on one axis only

Ragged lists are legal; they just have less shape for the shapely operations to work with.

A range is a compact list of consecutive integers, written with ..:

1..5            ; {1, 2, 3, 4, 5} - inclusive at both ends
5..1            ; {5, 4, 3, 2, 1} - descending is inferred from the endpoints
0..<5           ; {0, 1, 2, 3, 4} - ..< excludes the end
5..<5           ; {}              - an empty range is a legal empty list

Give a range its second element and you have set its step:

0..2..10        ; {0, 2, 4, 6, 8, 10}   - "0, then 2, up to 10"
1..3..10        ; {1, 3, 5, 7, 9}       - the end is a limit, not a promise
10..8..1        ; {10, 8, 6, 4, 2}      - stepping down
0..2..<10       ; {0, 2, 4, 6, 8}       - ..< goes on the final separator

Either end may be left off - 2.., ..7, 0..2.. or just .. - giving an unbounded range. There is nothing to enumerate, so these are not data; they exist to be subscripts, where the axis supplies the missing end.

One habit to note: .. binds looser than arithmetic and comparisons, so 0..n-1 means 0..(n-1) with no parentheses needed.

Since EQUB flattens whatever it is given into bytes, lists and ranges make data tables one-liners:

EQUB 1..5                       ; 01 02 03 04 05
EQUB REPEATED(16, &FF)          ; sixteen &FFs
EQUB RND(REPEATED(8, 256))      ; eight random bytes
EQUW table + (0..<32) * 8       ; a table of 32 pointers, stride 8

(EQUB and EQUS are the same directive - strings emit their characters, either name takes anything - and EQUW / EQUD emit 16- and 32-bit little-endian units.)

Subscripts

x[...] picks elements out of a list, range or string. There are three kinds of selector, and they do different things:

L = {10, 20, 30, 40, 50}

L[1]            ; 20
L[1..3]         ; {20, 30, 40}
L[{0, 4}]       ; {10, 50}
L[{1, 1, 1}]    ; {20, 20, 20} - gathers may repeat

Unbounded ranges shine here - the axis fills in the missing end:

L[3..]          ; {40, 50}      - from index 3 to the end
L[..1]          ; {10, 20}      - from the start up to index 1
L[..]           ; the whole thing
L[0..2..]       ; {10, 30, 50}  - every other element
L[4..0]         ; {50, 40, 30, 20, 10} - a descending slice reverses
L[3..10]        ; {40, 50}      - slices clip; no error

For nested lists, separate a selector per axis with commas, outermost axis first. An integer drops its axis; a range or list keeps it:

M = {{1, 2, 3}, {4, 5, 6}}

M[1]            ; {4, 5, 6}     - row 1
M[1, 2]         ; 6             - row 1, column 2 (M[1][2] means the same)
M[.., 0]        ; {1, 4}        - every row's column 0
M[.., 1..]      ; {{2, 3}, {5, 6}} - all rows, columns 1 onward

Fewer selectors than axes just leaves the trailing axes whole - which is why M[1] is a row.

A string is a single value (see Strings and character codes), but subscripts are one of the few things that look inside one. A string subscripts like a list of characters, and always gives back a string:

"hello"[1]      ; "e"
"hello"[1..3]   ; "ell"
"hello"[4..0]   ; "olleh"
"hello"[{0,4}]  ; "ho"

Broadcasting

Operators (and the element-wise functions) work on lists by mapping across them, NumPy-fashion. The rules, in the order they apply:

  1. A range becomes its list of elements.
  2. A single value - a number, or a string - pairs with everything in the other side, however deep.
  3. When both sides are lists of the same depth, elements pair off - lengths must match, except that a length 1 list repeats itself.
  4. When one side is shallower, it is held whole and applied to each element of the deeper side.
{1, 2, 3} * 2               ; {2, 4, 6}                 - rule 2
{1, 2, 3} + {10, 20, 30}    ; {11, 22, 33}              - rule 3
{1, 2, 3} + {10}            ; {11, 12, 13}              - rule 3, the 10 repeats
{1, 2} + {1, 2, 3}          ; error: shapes do not match
{{1, 2}, {3, 4}} * 10       ; {{10, 20}, {30, 40}}      - rule 2, all the way down

Rule 4 is where it gets properly useful. A rank-1 list against a rank-2 one applies to each row:

{{1, 2, 3}, {4, 5, 6}} * {1, 0, 0}   ; {{1, 0, 0}, {4, 0, 0}} - masks every row

and a column against a row gives you all the combinations - the outer product:

{{1}, {2}} + {10, 20}       ; {{11, 21}, {12, 22}}

The reductions collapse the results back down: with no axis they fold everything to one scalar; given an axis (0 = outermost) they collapse just that one:

SUM({{1, 2, 3}, {4, 5, 6}})         ; 21        - the lot
SUM({{1, 2, 3}, {4, 5, 6}}, 0)      ; {5, 7, 9} - down the columns
SUM({{1, 2, 3}, {4, 5, 6}}, 1)      ; {6, 15}   - along the rows
SUM(M * v, 1)                       ; matrix x vector, in two functions

And now the party trick from earlier makes complete sense - a range broadcasts through the maths and each result lands as a byte:

ALIGN &100
.sintable
    EQUB ABS(SIN((0..255) / 128 * PI)) * 255 + 0.5

A full 256-entry sine table, one line.

What maps, and what doesn't

Broadcasting belongs to the operators and the element-wise functions: ABS, SIN, LO, ROUND, CODES and the rest of that table. Each of them works on one value at a time, so given a list, it works on every single value inside it, however deep.

Every other function takes its arguments whole. These are the ones that only make sense of a list as a list - its length, its order, its total:

ABS({-1, {-2, 3}})          ; {1, {2, 3}}      - element-wise: every value inside
LEN({{1, 2}, {3, 4, 5}})    ; 2                - whole: the outer list has two elements
REVERSE({{1, 2}, {3, 4}})   ; {{3, 4}, {1, 2}} - whole: the rows swap, their contents don't
SUM({{1, 2}, {3, 4}})       ; 10

There's one exception, and it's a useful one: FIND's second argument maps, so asking for a list of things gives back a list of positions. That's what makes character mapping a one-liner.

Functions take their arguments whole

Your own FUNCTIONs take their arguments whole too - a list arrives as one value. But when everything in the body maps, so does the function:

FUNCTION sqr(x) = x*x

sqr({1, 2, 3})              ; {1, 4, 9} - because x*x maps

An IF doesn't map. It runs one branch or the other, so it needs a single value to decide on, and {1, 300} > 255 is {FALSE, TRUE} - an answer per element, not one answer:

FUNCTION clamp(x)
    IF x > 255
        r = 255
    ELSE
        r = x
    ENDIF
= r

clamp(300)                  ; 255
clamp({1, 300})             ; an error - the IF can't decide for a whole list

So a FUNCTION with an IF in it is one for single values - unless the choice is made with WHERE, the element-wise IF. For each value in its first argument, it takes the matching value of the second where that's true, and of the third where it's false:

FUNCTION clamp(x) = WHERE(x > 255, 255, x)

clamp({1, 300})                     ; {1, 255}
WHERE({TRUE, FALSE}, "on", "off")   ; {"on", "off"}

Its three arguments broadcast together, just as an operator's two do. And like an IF, it only looks at what it picks, so WHERE(x = 0, 0, 1/x) is safe even where x is 0.

Strings and character codes

A string is text in double quotes - write "" for a quote inside one. Two strings join with +, and compare in character-code order with <, = and the rest (so "Z" < "a"). SORT, MIN and MAX use the same order:

"HEL" + "LO"            ; "HELLO"
"ab" < "b"              ; TRUE
SORT({"pear", "apple"}) ; {"apple", "pear"}

A string is one value

A string is a single value, just as a number is - not a list of characters. Everything that builds lists, maps over them or measures their shape treats a string as one thing:

{"a", "b"} + "!"        ; {"a!", "b!"} - "!" pairs with each string, as a number would
CONCAT("ab", "cd")      ; {"ab", "cd"} - two strings make a list of two
FLATTEN({"ab", {"c"}})  ; {"ab", "c"}  - a string is already as flat as it gets
RANK("abc")             ; 0            - one value, no axes

It's also why + joins strings: adding two pieces of text can only mean joining them. A list is many values, so + on lists adds the values inside, and lists join with CONCAT instead.

SUM is + over a whole list, so it joins a list of strings. A list can run over several lines, which makes this the tidy way to build a long string from pieces:

title = SUM({"STARGLOBE v", AS_STRING(version),
             " by ", author})

Numbers don't join quietly - "v" + 3 is an error - so turn them into text first, with AS_STRING (or ~ for hex).

Looking inside a string

A handful of things work along a sequence: subscripts, LEN, REVERSE, FIND and REPLACE. Those read a string as its characters, and give back a string (or a position in one):

"hello"[1..3]               ; "ell"
LEN("hello")                ; 5
REVERSE("hello")            ; "olleh"
FIND("hello", "ll")         ; 2
REPLACE("1.2.3", ".", "-")  ; "1-2-3"

Hand them a list and they do the same job with its elements. Baron has no separate character type - a single character is just a one-character string - so FIND in a string looks for a piece of text, of any length.

FOR isn't one of them: it walks lists, and a string is a single value. Walk its codes instead, with FOR c = CODES(s).

Searching and replacing

FIND(x, v) gives the position where v first appears in x, and REPLACE(x, old, new) swaps every appearance of old for new, working left to right:

REPLACE("aaa", "aa", "b")       ; "ba" - appearances never overlap
REPLACE("v1.2", "v", "")        ; "1.2" - an empty new deletes

A REPLACE that finds nothing hands x back unchanged. A FIND that finds nothing is an error - unless you give it a third argument, which it hands back instead. That's the way to ask whether something is there at all:

IF FIND(name, ".", -1) >= 0     ; does name contain a dot?

Both work on lists as well. There, REPLACE swaps runs of elements, and old and new can each be a list or a single value:

REPLACE(bytes, &0D, {&0D, &0A}) ; every CR becomes CR LF
REPLACE(bytes, {0, 0}, {})      ; every pair of zeros goes

FIND is different on a list: its second argument maps, so a list there means "find each of these", and you get a position apiece. FIND({1, 2, 3, 4}, {2, 3}) is {1, 2}, not where the run 2, 3 starts.

Character codes

CODES turns a string into the list of its character codes, and CHR turns codes back into a string:

CODES("AB")             ; {65, 66}
CHR(72)                 ; "H"
CHR({72, 73})           ; "HI" - every code becomes a character of ONE string

CODES is element-wise, so a list of strings gives one list of codes per string. CHR takes its argument whole, and makes one string from every code in it, however nested:

CODES({"AB", "C"})      ; {{65, 66}, {67}}
CHR({{65, 66}, {67}})   ; "ABC"

That bridge, plus the machinery you already have, is the whole story of character mapping. A CODES result is a list, so it broadcasts - and a list of indices is a gather subscript, so indexing a table with one is the character map, NumPy-fashion. FIND(x, v) supplies the indices: it returns where v first occurs in x, and - the one exception to whole arguments - a list of vs gives a list of positions.

So BeebAsm's MAPCHAR is a one-liner. Map each character to its position in a glyph set:

glyphs = " ABCDEFGHIJKLMNOPQRSTUVWXYZ!?"

FUNCTION mapchar(s) = FIND(CODES(glyphs), CODES(s))

EQUS mapchar("HELLO WORLD!")    ; each byte is its character's index into glyphs

Or keep explicit {old, new} tables and look bytes up through both:

from = CODES("ABC")
to   = {&80, &90, &A0}

FUNCTION remap(s) = to[FIND(from, CODES(s))]

Or, when the mapping is just an offset, skip the function entirely - a character literal is a number, so it broadcasts across the codes:

EQUS CODES("HELLO WORLD") - ' '   ; font starts at space = 0

A character missing from the set fails the assemble with Not found: '81' naming the stray code - which is exactly what you want from a font with holes in it. If you'd rather have a stand-in, give FIND a third argument: FIND(CODES(glyphs), CODES(s), 0) sends anything missing to glyph 0.

Two type predicates round the toolkit out: IS_STRING(x) and IS_NUMBER(x) answer TRUE or FALSE for the value as a whole (a list is neither; test list-ness with RANK(x) > 0) - handy for a FUNCTION that accepts either a string or a list of codes.

Numbers as text

AS_STRING writes a number as decimal text, just as PRINT would show it, and AS_NUMBER reads one back - the conversions to go with IS_STRING and IS_NUMBER:

AS_STRING(1234)         ; "1234"
AS_STRING(1.5)          ; "1.5"
AS_NUMBER("42")         ; 42
AS_NUMBER("&1F40")      ; 8000

That's handy wherever a number has to end up as characters, like a version number inside a BASIC line:

version = 3

BASIC
10PRINT "Version {
    EQUS AS_STRING(version)
}"
ENDBASIC

AS_NUMBER takes exactly one number, written the way you'd write it in source: - and + are fine, and so are &, $ and %. Anything else in the string, a space included, is an error naming the string, so AS_NUMBER("12x") fails with Not a number: '12x' rather than quietly giving 12. For hex text, there's ~ (see Logging), and AS_NUMBER("&" + ~n) gets you n back.

Zero-page allocation

The headline feature. Hand Baron a pool of zero-page bytes and declare variables by name; it runs a liveness analysis over your actual code and packs them like a compiler allocating registers - provably safely, or it refuses:

ZA_POOL &70..&8F                ; the pool

.multiply
{
    ZA_AUTO1 a2, b2             ; one-byte variables
    ZA_AUTO2 ptr                ; a two-byte pair - a pointer
    STA a2
    ...
}

Variables whose lifetimes never overlap share bytes automatically; anything held live across a JSR is kept clear of everything the callee touches. It is entirely opt-in - no ZA_POOL, no allocator - and it has a whole guide to itself: Zero page allocation.

IF and FOR

IF / ELIF / ELSE / ENDIF assembles conditionally. The condition is any expression - a boolean (TRUE, FALSE, a comparison), or a number read as nonzero-is-true - and a dead branch emits nothing at all. Nor does it compute anything: its lines are read only to find where the branch ends, so a FUNCTION call, a builtin like LZ_PARSE or an RND in it costs nothing and draws nothing. The same goes for the branches of an IF inside a FUNCTION.

debugrasters = FALSE

IF debugrasters
    LDA #&00 + PAL_red : STA &FE21
ENDIF

FOR ... NEXT runs its body once per element of a range or list, with the loop variable bound to each element in turn:

FOR n = 0..7
    LDA #n : STA &FE21          ; unrolled eight times
NEXT

FOR addr = {&FE44, &FE45, &FE4B}
    STA addr
NEXT

Each iteration gets its own scope, so labels inside the body do not collide from one iteration to the next. And because these are statements, not preprocessing, they nest freely with everything else - macros, includes, other loops.

Predefined symbols

The -D switch defines a symbol from the command line, before any source is read - the build script's way of steering the assembly:

baron -D DEBUG=TRUE -D screenwidth=64 -D version="1.0" game.6502

Each -D takes one name=expression argument with no spaces in it, and binds the name in the top-level scope of every file on the line. The right-hand side is a full Baron expression - numbers, strings, built-in constants and functions, even references to symbols the source itself defines later (they settle over the passes like any forward reference). The one thing it cannot do is call a FUNCTION from the source: functions do not exist yet when the definitions bind.

A predefinition is an ordinary symbol, and symbols bind once: if the source also assigns DEBUG = 0, that is a duplicate-symbol error, with the note pointing back at the -D. To give the source a default for when the switch is absent, test with DEFINED() and bind a different name:

IF DEFINED(debug)
    dbg = debug         ; the command line spoke
ELSE
    dbg = FALSE         ; the default
ENDIF

(Guarding an assignment to the same name - IF DEFINED(debug) == FALSE : debug = 0 : ENDIF - cannot settle: binding the symbol flips its own condition on the next pass. Baron reports it as Assembly did not settle: 'debug' kept changing, pointing at the binding - alongside an undefined-symbol error at any use the flip strands.)

Macros

A macro is a named statement sequence, expanded wherever its name appears:

MACRO ST16 addr, val
    LDA #LO(val) : STA addr
    LDA #HI(val) : STA addr+1
ENDMACRO

ST16 &70, screenbase

Arguments are values, evaluated at the call site; the body expands in its own scope, so its labels are private. Define a macro before its first use - a call earlier in the file will not recognise the name.

One name can carry several overloads, distinguished by shape. Punctuation in a signature is quoted and matched verbatim at the call, which lets a macro mimic addressing-mode syntax:

MACRO ADD n            : CLC : ADC n  : ENDMACRO
MACRO ADD "#" n        : CLC : ADC #n : ENDMACRO

ADD &70                 ; picks the first
ADD #3                  ; the '#' picks the second

Recursion works, with an IF for the base case:

MACRO FILL n
    IF n > 0 : EQUB n : FILL n-1 : ENDIF
ENDMACRO

Functions

Where a macro emits statements, a FUNCTION computes a value - usable in any expression. The one-liner form:

FUNCTION sqr(x) = x*x

EQUB sqr(0..15)                 ; maps over the range, because x*x does

A function takes each argument whole, so a list arrives as one value; it maps over the list only when everything in its body does (see Functions take their arguments whole).

Longer functions take local assignments, IFs and FOR loops, with a top-level = as the return:

FUNCTION ball_ypos(n)
    x = (n - numdots/2 + 0.5) / (numdots/2)
    y = (x - SIN(x*PI) * 0.1) * radius
= 128 + y

Functions are pure - they compute, they never emit - and lexically scoped: a body sees its parameters and the globals of where it was defined, never the caller's locals. Overload by parameter count, and recurse away (IF for the base case, as with macros). Definition before use, as with macros.

Recursion can go 128 calls deep; deeper than that is an error (usually a missing base case). To walk a long list, use a loop.

Functions are powerful enough to be able to implement something like the quicksort algorithm, taking a list and returning it sorted!

Locals and loops

A local takes a new value each time you assign it, parameters included, so a body can count and accumulate. That's the only change a body can make: assigning the name of a global makes a local that hides it, and the locals are gone when the call returns, so the function stays pure. A local can be read only once something has assigned it.

FOR ... NEXT works in a body just as it does in a program, running its statements once for each element of a list or range. With assignment, that's how to build a list a piece at a time:

FUNCTION commands(names)            ; a length-prefixed command table
    out = {}
    FOR s = names
        out = CONCAT(out, LEN(s), CODES(s))
    NEXT
= out

EQUB commands({"LOAD", "SAVE", "RUN"})

The loop variable is an ordinary local, which keeps its last value after the NEXT, and anything else you assign carries from one step to the next:

FUNCTION offsets(lengths)           ; where each record starts
    out = {}
    at = 0
    FOR n = lengths
        out = CONCAT(out, at)
        at = at + n
    NEXT
= out

EQUW records + offsets({5, 3, 8})   ; records, records+5, records+8

A loop is also the way to apply something that takes its argument whole to each element of a list - here LEN of each string, which LEN on the list won't give you:

FUNCTION lengths(strings)
    out = {}
    FOR s = strings
        out = CONCAT(out, LEN(s))
    NEXT
= out

lengths({"LOAD", "SAVE", "RUN"})    ; {4, 4, 3}

A few things to know:

Errors in a body

An error inside a body is reported on the body line that caused it, followed by a note at the call it came from. A bad argument is the caller's mistake, so that one is reported at the call.

To refuse bad input, put an ASSERT in the body. When its condition is false, the call's result is an error carrying your message - the values joined, as ERROR joins them - reported at the ASSERT:

FUNCTION checked(w)
    ASSERT w >= 0, "bad width: ", w
= w

The message is optional: a bare ASSERT w < 100 just reports "Assertion failed". It is only evaluated when the assertion fails.

When the refusal is one outcome among several, ERROR(...) is also a function: it returns an error value carrying your message, which surfaces as a proper diagnostic once the result is used:

FUNCTION checked(w)
    IF w < 0
        r = ERROR("bad width: ", w)
    ELSE
        r = w
    ENDIF
= r

Section transforms

A section can have its bytes rewritten as it closes - compressed, scrambled, checksummed - by a FUNCTION of your own. Name it in the section's transform attribute:

FUNCTION scramble(data) = data EOR &55

SECTION Loader, org = &1900, filename = "LOADER"
    ...                         ; the unscrambler
.payload
SECTION Code, org = &400, transform = "scramble"
.start
    ...                         ; the real code, assembled for &400
ENDSECTION
.payload_end
ENDSECTION

The FUNCTION is given the section's bytes as a list of numbers, and returns the bytes to keep: a list of numbers 0-255, or a string. Those are what the section holds and what its parent receives - here, the loader holds the scrambled bytes between .payload and .payload_end. The section's own labels stay where the original code will run once it is unscrambled, so JMP start still means &400.

The transform runs once everything else has settled - every label resolved, zero-page variables allocated, INCBIN files loaded - so it sees the bytes exactly as they will be. If it changes their size, whatever comes after the section moves, so Baron goes round again until the sizes hold still. Given the same bytes again, a transform is not run again. (A transform whose output size depends on where it lands may never settle, which is reported.)

-v lists a transformed section as it was assembled, followed by a note of what the transform made of it:

ENDSECTION
; transform "scramble": 312 -> 312 bytes

Bit fields

A compressor writes fields of so many bits. In Baron, a field of n bits holding v is the single number 2^n + v: the top set bit marks the width, and is not part of the value. Two builtins work with them:

Encodings are then ordinary FUNCTIONs, and like any arithmetic they work on whole lists at once:

FUNCTION bits(v, n) = 2^n + v                   ; v in n bits
FUNCTION gamma(n)   = 2^(2 * BITLEN(n) - 1) + n ; an Elias gamma code, n >= 1

BITPACK({bits(5, 3), gamma(5)})                 ; 101, then 00101: {165}

Compression

LZ_PARSE(data, literal_run, literal, match_run, length, offset) finds the cheapest way to write data as runs that alternate between literal bytes and matches (copies of earlier bytes), literals first, each run's count written before its contents. It knows nothing of how any field is written. Instead, five tables give the cost in bits of every value a field could hold, by value, with a negative entry meaning "impossible". The tables come straight from the encodings: BITLEN(gamma(0..n)) - 1 is the width of the gamma code for every count up to n, and gamma(0) is no field at all, so a count of 0 comes out impossible on its own.

It returns {bits, runs}: the smallest size there is under those costs, and the runs that achieve it - {0, bytes} for a literal run, {1, {offsets, lengths}} for a match run - for your FUNCTIONs to encode and BITPACK.

Some formats price an offset by the length of its match - a short match can only reach so far back, say. Then offset can be a list of tables, one for each length from 1, the last serving every longer match: {off1, off2, off3} prices 1-byte, 2-byte, and longer matches differently.

What it assumes

How a format marks its runs decides what the search can take for granted:

Repeat matches

Some formats also have repeat matches, which copy from the same offset as the match before: the first match after a literal run may say "same offset again" in a few bits instead of writing the offset out. That pays because data so often agrees with an earlier stretch except for the odd byte - a match, a literal, then the match carries on. A seventh argument, repeat, gives a repeat's cost by its length:

LZ_PARSE(data, literal_run, literal, match_run, length, offset, repeat)

A repeat comes back with an offset of 0. The search is still exact, though it has more to keep track of: which offset is worth having in hand depends on the bytes still to come, so it tries every offset for the last match of each run.

Some formats allow a repeat only after certain literal runs, and make every match there pay to say whether it is one. Exomizer, for instance, allows one only straight after a single literal, where every match carries a flag bit. Then repeat is a pair, {lengths, after}: after gives, by the count of the literal run before, what any match there pays first, with a negative entry where no repeat can follow:

LZ_PARSE(data, literal_run, literal, match_run, length, offset, {repeat, {-1, 1}})   ; Exomizer's rule

(The first literal run never has an offset in hand, so it pays nothing.) The search assumes that neither a literal run's count, with what a match after it pays, nor a repeat's length ever gets cheaper as it grows.

Examples

examples/compress puts it all together, four times:

Each has a loader whose decompressor unpacks the same title screen straight into screen memory, and all four loaders go on one disc. ZX02's decompressor is the smallest and quickest to unpack; Exomizer packs the screen smallest.

Logging

PRINT writes text during assembly - values concatenated with no separator, one newline per statement:

PRINT "code ends at ", *, " (", * - start, " bytes)"

Strings print raw and numbers in decimal; spacing is yours to supply. A PRINT speaks exactly once per assembly, on the final pass, with everything resolved.

For the BBC's own dialect, ~ turns a number into a hex string - two digits, four or eight, whichever is the narrowest that holds it, and the & is yours to write:

PRINT "code ends at &", ~*, " (", * - start, " bytes)"     ; code ends at &1F40 (2112 bytes)

Like < and >, it swallows the whole expression after it, so ~start+1 is the hex of start+1. For decimal text, there's AS_STRING (see Numbers as text).

There are ten channels, #0 to #9, selected by a leading #n,:

PRINT #1, "symbol dump: start = ", start

Channel 0 is the default and goes to stdout (interleaved with the -v listing, if you asked for one). Channels 1-9 go nowhere unless the command line redirects them: -log1 symbols.txt collects everything printed to #1 into that file. -log0 captures channel 0 too, if you want stdout kept clear.

ERROR is PRINT's grumpy sibling: it reports its message as a genuine assembly error and fails the build (though assembly carries on, so you still get the full report):

IF * > &3000 : ERROR "code overran the screen by ", * - &3000, " bytes" : ENDIF

ASSERT says the same thing the other way round, in one line - this is the IF above:

ASSERT * <= &3000, "code overran the screen by ", * - &3000, " bytes"

The message is only evaluated when the assertion fails, and it is optional: without one you get "Assertion failed". ASSERT works inside a FUNCTION body too.

The name ERROR doubles as a function in expression position - ERROR("bad width: ", w) returns an error value that reports once it is used, which is how a FUNCTION body refuses bad input.

The symbol dump

--symbols <file> writes everything Baron resolved - every symbol, where it was defined, and the section it belongs to - to one JSON file once the whole run has assembled:

$ baron --symbols syms.json main.6502 tools.6502

Take this main.6502, which includes a zp.6502 holding the single line ptr = &70:

INCLUDE "zp.6502"

width = 32

SECTION game, org=&3000, filename="GAME"
.start  LDA #width
        SECTION handler, org=&0400
.irq    RTI
        ENDSECTION
.done   RTS
ENDSECTION

Its dump looks like this:

{
  "format": 2,
  "assemblies": [
    {
      "sources": ["main.6502", "zp.6502"],
      "sections": [
        {
          "parent": null, "size": 0,
          "assignments": {
            "ptr": {"value": 112, "source": 1, "line": 1},
            "width": {"value": 32, "source": 0, "line": 3}
          }
        },
        {
          "name": "game", "parent": 0, "size": 4,
          "attributes": {
            "org": 12288,
            "filename": "GAME"
          },
          "labels": {
            "done": {"value": 12291, "source": 0, "line": 10},
            "start": {"value": 12288, "source": 0, "line": 6}
          }
        },
        {
          "name": "handler", "parent": 1, "size": 1,
          "attributes": {
            "org": 1024
          },
          "labels": {
            "irq": {"value": 1024, "source": 0, "line": 8}
          }
        }
      ]
    }
  ]
}

Assemblies

assemblies has one entry per source file on the command line, in order. Each file assembles in its own universe, so their symbols are never merged.

Indices are local to their entry: "source" indexes that entry's sources, and "parent" indexes its sections. Two files each have their own source 0 and their own section 0.

Sources

sources lists every file the assembly read, each once, however many times it was included. sources[0] is the file named on the command line. A predefined symbol gets a source of its own, named after its switch - -D DEBUG=1.

Sections

sections lists every section in the order it opened. Entry 0 is the file's default section, which holds everything outside a SECTION: it has no name and no parent, and its size counts only the bytes written outside every section - none, above.

A nested section's bytes are part of its parent's, so a parent's size includes its children - game above is 4 bytes, handler's RTI among them. A top-level section's parent is 0, the default it was written in, though none of its bytes land there. A reopened section appears once, with its parent taken from where it was first written and a size covering every piece.

attributes holds the attributes exactly as the SECTION line wrote them, in order. A section with no attributes leaves it out.

Since nothing is inherited, an org appears only on a section that set one. That tells you about addresses: a section with an org starts addresses of its own, while one without carries on from its parent's. To find which address space a label lives in, walk up parent to the nearest section with an org. Here, irq and start are in different spaces even though both sit inside game's bytes.

Symbols

Each symbol sits in the innermost section open where it was defined, keyed by its full dotted path. Within a section, symbols are grouped by kind:

Group Holds
labels .name and .@ labels - always addresses
assignments name = expr
defines -D predefines
za_autos ZA_AUTO variables - each one's allocated address
loop_vars FOR variables, one per iteration
params macro parameters

A group with nothing in it is left out. For a label, its section is the one whose bytes it addresses. Any other kind just lands in whichever section its line sits in.

Each symbol gives its value, plus the source and line of the statement that defined it. For a label inside a macro, that is the line in the macro's body, while its section is wherever that expansion was assembled.

Values keep their types: numbers as numbers, booleans as true/false, strings as strings, and lists and ranges as arrays.

Grepping and diffing

Symbols are sorted within their group, one per line, so grep '"start"' syms.json answers with its address and line, and two builds' dumps diff cleanly.

Baron's internals are included too: symbols inside anonymous { } scopes, per-iteration FOR frames, macro calls, and local labels appear under unspellable @ keys ("@0:3:0.i" is iteration 0's i). Filter out keys containing @ if only the names you wrote matter. A FUNCTION call's parameters and locals never appear: they only exist while the call runs.

Like every other output, the file is only written when every file assembled, and --check skips the write.

Saving your work

Naming a file is the request to save: every section with a filename attribute is a candidate, and nothing else is. Where those candidates actually go is the command line's call, and there are two destinations:

Neither is implied, and they are not exclusive - baron -p build -o game.ssd ... writes both. A run given neither still assembles, reports, lists and prints; it simply has nowhere to put the bytes, and warns you in case that was not what you meant. (--check is the way to say it deliberately: assemble and validate everything, write nothing, no grumbling.)

A multi-file run (baron -p . main.6502 loader.6502) assembles each file independently and pools all their saved sections into the output. If anything failed to assemble, nothing at all is written - no half-built discs.

Examples

Take a look in the examples/ directory for some Baron source code samples, including the obligatory spinning star globe (now with even more dots!), and a title screen compressed two ways by section transforms as it assembles.


That is the tour. The full switch, keyword and operator tables await in the reference.