c65gm has a feature that lets you generate assembly code at compile time. It uses Starlark, a Python-like language, embedded directly in your source files with SCRIPT and SCRIPT LIBRARY blocks. Here is how they work and why you would want to use them.

The Basics

A SCRIPT block runs during compilation. Everything you print() from it goes straight into the assembler output.

// @show-asm
ORIGIN $C000 //ACME needs an start address
SCRIPT
    for i in range(256):
        print("    !8 %d" % i)
ENDSCRIPT

That is a 256-byte data table in five lines. No copy-paste, no mistakes in the middle values. The assembler never knows it wasn't written by hand.

Sine tables are the classic use case on the C64:

// @show-asm
ORIGIN $C000
SCRIPT
    print("sintable:")
    for i in range(256):
        angle = (i * 2.0 * math.pi) / 256.0
        sine = math.sin(angle)
        value = int((sine + 1.0) * 127.5)
        print("    !8 %d" % value)
ENDSCRIPT

The math module gives you pi, sin, floating point, everything. The result is a block of 256 bytes embedded in your PRG. No floating point code makes it into the binary. It all runs at compile time.

Change the amplitude? Change the table size? Change one number, not 256.

Using c65gm variables in scripts

Your SCRIPT blocks can reference c65gm variables with the |varname| syntax. The marker gets replaced with the assembly label. This means your generated code can read and write real c65gm variables.

syntax:

// @show-asm
ORIGIN $C000

FUNC my_func
  BYTE tableSize
  
  SCRIPT
      print("table_data:")
      for i in range(64):
          print("    !8 %d" % (i * 2))
      print("table_end:")
      print("  lda #<table_end - table_data")
      print("  sta |tableSize|")
  ENDSCRIPT
FEND

The |tableSize| marker gets replaced with the assembly label for that local variable. The generated code and the rest of your program can reference each other. Simple.

SCRIPT LIBRARY, Reusable Code Generation

SCRIPT LIBRARY blocks define Starlark functions that persist across all subsequent SCRIPT blocks in your compilation.

You write the same patterns over and over. NOPs for timing. Delay cycles. Setting up IRQ vectors. SCRIPT LIBRARY lets you write them once.

// @show-asm
ORIGIN $C000
SCRIPT LIBRARY
def emit_nops(count):
    for i in range(count):
        print("  nop")

def emit_delay(cycles):
    for i in range(cycles // 4):
        print("  nop")
        print("  nop")
    remainder = cycles % 4
    if remainder >= 3:
        print("  bit $ea")
        remainder -= 3
    for i in range(remainder // 2):
        print("  nop")

        
def emit_border_flash(color1, color2):
    print("  lda #%d" % color1)
    print("  sta $d020")
    print("  lda #%d" % color2)
    print("  sta $d020")
ENDSCRIPT

// Then call them from any SCRIPT block that follows

SCRIPT
    emit_nops(5)
    print(" ; now the emit_delay(11) - 11 cycles")
    emit_delay(11)
    emit_border_flash(2, 6)
ENDSCRIPT

The emit_delay function handles arbitrary cycle counts, falling back to a BIT $EA trick for odd remainders. You would never write that inline every time. Now you don't have to.

What to do with this

Precompute CRC tables. Implement the polynomial in Starlark, output the 256-byte lookup. Zero runtime cost. Generate color sequences from mathematical formulas. Fades, cycles, plasma palettes. Tweak the formula, recompile, check the result. Procedural map data. Starlark is deterministic (no random(), no filesystem, immutable globals), so the same source always produces the same binary. Implement a simple LCG in Starlark and your dungeon layout is reproducible across every build. No seed tracking, no surprises.

Compile-time validation with fail():

ORIGIN $C000
SCRIPT
    # some logic leading to sprite_count 9 assumed:
    sprite_count = 9
    if sprite_count > 8:
        fail("Only 8 hardware sprites supported")
ENDSCRIPT

Pointless to discover this on a real C64. Catch it at compile time. (You can copy&paste this into the Try c65gm editor on this site and you'll see the actual compile time error)

Starlark is sandboxed on purpose

The Go Starlark implementation that c65gm uses is fully sandboxed. No file I/O. No network. No way to touch the host system. Execution is capped at one million steps so an accidental infinite loop won't hang your build. Immutable globals mean a library include cannot silently change a value you set earlier.

This means you can download any c65gm project from the internet with SCRIPT LIBRARY includes and run it without auditing the Starlark code first. The script literally cannot do damage. That is the point.

The trade-off is that any data your SCRIPT blocks work with must be computed from scratch or defined inline as Starlark literals. You cannot open a sprite file and RLE-encode it. What you can do, if you need it, is use an ASM-block and use a normal include binary directive in ACME assembler. For what you actually want to generate at compile time (math, structural data, lookup tables) this is rarely a problem.

Zero runtime cost

None of this costs anything at runtime. The Starlark runs during the compile. The output is plain text fed to the ACME assembler. The bytes in your PRG are indistinguishable from hand-written ones. No wrappers, no hidden calls, no interpreter. What you generate is what you get.

For cycle-counting code, for size-sensitive routines, for people who care about exactly what the 6502 is doing, this matters. You are not giving up control. You are just writing less of the repetitive parts by hand.


SCRIPT blocks are one of those features that seems optional until you start using it, and then it becomes the first thing you reach for.