Cookbook: expressions and macros

Everywhere a driver supplies a value that isn’t a plain label or number — a byte pulled from an address, a masked offset, a computed table entry — it uses an assembler-neutral expression. dasmos renders that one expression into whichever assembler you target: beebasm’s & hex and AND keyword, 64tass’s $ and &, each with the parentheses that that assembler’s operator precedence requires. When the same shaped expression recurs across a table, you can lift it into a macro — and dasmos emits a real macro definition and invocations, in each backend’s own macro construct.

This cookbook builds up from a one-liner to the BBC BASIC inline- assembler mnemonic-hash tables, which exercise the whole system. Every rendered listing shown here is real output; every expression assembles back to the original bytes on both beebasm and 64tass.

The DSL lives in dasmos.expr:

from dasmos.expr import ref, sym, lo, hi, hexlit, char, string, param

Note

ir.render("beebasm") returns a TextOutput, not a bare string — wrap it in str(...) (or print) to get the source text: print(str(ir.render("beebasm"))). The listings below are that string.

Recipe 1: an operand plus a constant

The simplest case — an immediate operand that reads better as constant + 1 than as a magic number. d.expr takes the operand byte address (one past the opcode on the 6502):

d.constant(3, "num_lives")
d.expr(0x8001, sym("num_lives") + 1)

Both backends render the same thing (the constant name is already valid in either), so this is indistinguishable from hand-written source:

lda #num_lives + 1

sym(name) is a bare symbolic name emitted verbatim. Use it for a constant or any name you’ve defined elsewhere. A plain Python int on the other side of the + becomes a literal automatically.

Recipe 2: the low byte of a label, minus one

The classic jump-table idiom: a byte table holding target-1 low bytes, pushed for an RTS dispatch. ref(addr) references the label at a runtime address and resolves to its name at render time — so it stays symbolic even if you name the target later in the driver:

d.label(0x8130, "dispatch_table")
d.expr(0x8005, lo(ref(0x8130) - 1))

Renders (identically on both backends, since <(...) is common syntax):

lda #<(dispatch_table - 1)

hi is the high-byte counterpart. Note you did not write any parentheses or a byte-select operator — lo and the subtraction are intent; the renderer supplies <(...) for beebasm and 64tass (and would supply LO(...) or >x for an assembler that spells it differently).

Tip

code_ptr() and rts_code_ptr() build these lo/hi of target-1 trees for you from a pair of table addresses — reach for the DSL directly only when the pattern is irregular.

Recipe 3: a masked difference of two labels

A table whose bytes are offsets between two labels, masked to a byte — common in string tables where an entry records “distance from the base”. Python’s operators compose the tree; & is bitwise-AND:

d.label(0x9000, "cmd_syntax_strings")
d.label(0x9040, "syn_opt_dir")
d.byte(0x9041, 1)
d.expr(0x9041, (ref(0x9040) - ref(0x9000)) & 0xFF)

The interesting part is what dasmos does not emit — redundant parentheses. AND/& binds looser than - in both assemblers, so the subtraction needs no wrapping, and the value is still correct:

; beebasm
equb syn_opt_dir - cmd_syntax_strings AND &ff

; 64tass
.byte syn_opt_dir - cmd_syntax_strings & $ff

If you want the grouping kept for readability, wrap it explicitly with group(...) (from dasmos.expr) and it renders with the parentheses on every backend.

Recipe 4: forcing radix and character literals

By default an integer renders with a small-int heuristic (0–9 decimal, larger values hex). Override per literal when the intent matters:

from dasmos.expr import hexlit, declit, char

hexlit(0x1F)     # &1f  / $1f
declit(128)      # 128  (not &80)
char(ord("A"))   # 'A'

char is the expression-level equivalent of char_literal(); use it inside a larger expression, and the sugar method for a standalone operand byte.

Recipe 5: string operations — keep the meaning visible

Some tables are computed from text. The canonical case is BBC BASIC’s inline assembler, whose mnemonic tables store a 15-bit “packed name” hash of each mnemonic — the low five bits of each of its three letters, packed together — split across a low-byte and a high-byte table.

You could spell the hash with three character literals. But dasmos has string expressions, so you can write it in terms of the mnemonic string itself — and, by default, the string stays visible in the disassembly, because a listing exists to be read:

from dasmos.expr import string, group

def hash_lo(mnemonic):
    s = string(mnemonic)
    key = (s[0] & 0x1F) * 0x400 + (s[1] & 0x1F) * 0x20 + (s[2] & 0x1F)
    return group(key) & 0xFF

d.label(0x8450, "asm_mnemonic_lo")
d.byte(0x8450, len(MNEMONICS))
for i, mnemonic in enumerate(MNEMONICS):
    d.expr(0x8450 + i, hash_lo(mnemonic))

string(m)[i] indexes the string; on a backend with native string indexing the mnemonic is right there in the output:

; 64tass — "LDA" indexed natively
.byte (("LDA"[0] & $1f) * $0400 + ("LDA"[1] & $1f) * $20 + ("LDA"[2] & $1f)) & $ff

; beebasm — via ASC(MID$(...)), still showing the string
equb ((ASC(MID$("LDA", 1, 1)) AND &1f) * &0400 + ...) AND &ff

Both assemble to the same hash byte (&81 for LDA). String slicing (string("BRK")[0:2]) and length (str_len(...)) are available too.

Readability vs terseness. If you would rather the table read as bare values, ask the renderer to fold constant string operations:

from dasmos.ext.renderers.beebasm import BeebasmRenderer
ir.render(BeebasmRenderer(fold_string_ops=True))

which collapses "LDA"[0] to the character literal 'L':

equb (('L' AND &1f) * &0400 + ('D' AND &1f) * &20 + ('A' AND &1f)) AND &ff

Folding is also the automatic fallback for an assembler that has no string operations at all — the value is still emitted, just without the string. The default, though, is to show the string.

Recipe 6: lift a repeated expression into a macro

Recipe 5 emits the whole hash expression on every table entry — correct, but the listing is a wall of arithmetic. When the same shaped expression recurs, define it once as a macro and invoke it per entry. define_macro() takes a name, parameter names, and a body built with param() placeholders; it returns a callable that builds invocations:

from dasmos.expr import param, group

m = param("mnem")
pack_lo = d.define_macro("pack_lo", ["mnem"],
    group((m[0] & 0x1F) * 0x400 + (m[1] & 0x1F) * 0x20 + (m[2] & 0x1F)) & 0xFF)

d.label(0x8450, "asm_mnemonic_lo")
d.byte(0x8450, len(MNEMONICS))
for i, mnemonic in enumerate(MNEMONICS):
    d.expr(0x8450 + i, pack_lo(mnemonic))

Now the shape is written once and each entry reads as pack_lo("LDA"). The two shipped backends differ in how a macro can be used, and dasmos renders each in its native form.

64tass has value-returning functions, so the macro is a .sfunction and each invocation is a value used inline in the data directive:

pack_lo .sfunction mnem, ((mnem[0] & $1f) * $0400 + (mnem[1] & $1f) * $20 + (mnem[2] & $1f)) & $ff

    .byte pack_lo("LDA"), pack_lo("STA"), pack_lo("BRK")

beebasm has no value function, so the macro is a code macro that emits the byte, and each invocation is its own statement line:

MACRO pack_lo mnem
    equb ((ASC(MID$(mnem, 1, 1)) AND &1f) * &0400 + (ASC(MID$(mnem, 2, 1)) AND &1f) * &20 + (ASC(MID$(mnem, 3, 1)) AND &1f)) AND &ff
ENDMACRO

    pack_lo "LDA"
    pack_lo "STA"
    pack_lo "BRK"

Both assemble to the identical table bytes. You wrote one macro; dasmos chose the value-function form or the emit-a-line form according to what the target assembler supports. The emit keyword (define_macro(..., emit="word")) tells the statement-style backends which data directive to emit — EQUB vs EQUW — for a word-valued macro.

When a backend can’t express the macro, dasmos expands it. A macro used as an operand (lda #pack_lo("LDA")) or nested inside another expression needs a value — which beebasm has no construct for. Rather than fail, dasmos inline-expands the body at that call site (substituting the arguments) and emits a one-time UserWarning:

; 64tass keeps the value-function call
lda #pack_lo("LDA")

; beebasm inline-expands the body (the string stays visible)
lda #((ASC(MID$("LDA", 1, 1)) AND &1f) * &0400 + ...) AND &ff

Both assemble to the same byte. The macro definition is emitted only for backends that actually invoke it natively, so an expand-only use leaves no unused definition behind. This graceful degradation is what lets one macro target assemblers of differing power — a value function where available, inline expansion where not.

All the operators

Expressions compose with Python’s own operators (plus a few named builders). Every one below renders to each backend’s native token and is verified to assemble to the same value on beebasm and 64tass.

Binary operators

DSL / Python

Meaning

beebasm

64tass

a + b

addition

a + b

a + b

a - b

subtraction

a - b

a - b

a * b

multiplication

a * b

a * b

a // b

integer division

a DIV b

a / b

a % b

modulo

a MOD b

a % b

a & b

bitwise AND

a AND b

a & b

a | b

bitwise OR

a OR b

a | b

a ^ b

bitwise XOR

a EOR b

a ^ b

a << b

shift left

a << b

a << b

a >> b

shift right

a >> b

a >> b

Unary operators and byte-selects

DSL / Python

Meaning

beebasm

64tass

-a

negation

-a

-a

+a

unary plus

+a

+a

~a

bitwise complement

NOT(a)

~a

lo(a)

low byte

<(a)

<(a)

hi(a)

high byte

>(a)

>(a)

String operations (the string-operations recipe above) round out the set: index string(s)[i], slice string(s)[i:j], and str_len(string(s)).

Two gotchas worth internalising:

  • Use // for division, never /. In the DSL // is integer division (what a 6502 assembler does); Python’s / is float division and is rejected. // renders DIV on beebasm, / on 64tass — the same integer operation, spelled each assembler’s way.

  • Parenthesise bitwise masks. Python’s & / | / ^ bind looser than + and *, so a + b & 0xFF means a + (b & 0xFF) in Python. Write (a + b) & 0xFF (or wrap the sum in group()) when you mean “mask the whole thing” — the mnemonic-hash recipes do exactly this.

(~ maps to a bitwise-complement that beebasm has no operator for, so it renders as beebasm’s NOT(...) function — one example of dasmos choosing each assembler’s real spelling for you. A 65816 bank-byte operator exists in the model but has no 6502 rendering.)

How it renders correctly everywhere

Two properties do the heavy lifting, and they’re worth understanding because they’re why one expression can target many assemblers:

  • Precedence-aware parentheses. beebasm and 64tass rank operators differently (beebasm’s AND sits below +; 64tass follows C). dasmos parenthesises against each backend’s table, emitting the minimum needed for the value to be correct there. You never manage parentheses for portability.

  • Hex sigils, operator tokens, and byte-select spelling are per-backend choices, not something the driver encodes — see the operator table above. The one expression means AND on beebasm and & on 64tass, DIV versus / for integer division, and so on.

The structured form is also available to non-assembler consumers: the JSON renderer emits every expression as {"text": ..., "tree": ...} — a ready rendering plus a walkable tree — and macros as a top-level macros section. A downstream tool can re-render to any syntax or evaluate the expression itself.

Reaching further

The four assemblers dasmos knows about differ most in their macro models: 64tass has value functions and native string indexing; beebasm has code macros and MID$/ASC string functions but no value function; ca65 imitates a value function with .define and indexes with .strat; acme has neither. dasmos ships beebasm and 64tass today; the design that lets one macro render across all four — native construct where available, graceful degradation where not — is written up in docs/design/expression-system.md (see decisions D-026 and D-027).