Skip to content

Splitting a project into modules

Once a patch grows past one screen, splitting it into modules pays off: faster incremental builds, scoped symbol namespaces, and reusable helpers across hacks. This walkthrough builds a two-module project end to end.

Starting layout

my-hack/
├── a816.toml
├── src/
│   ├── main.s
│   └── modules/
│       └── vwf.s
└── build/

a816.toml:

entrypoint    = "src/main.s"
include-paths = ["src/include"]
module-paths  = ["src/modules"]

A reusable module

src/modules/vwf.s exports a small variable-width-font init routine:

"""VWF helpers shared across the hack."""

.scope vwf {
    init:
        """Initialise VRAM tile slots used by the VWF renderer."""
        lda.b #0x00
        sta.w 0x2115
        rts

    ; private — only callable from inside this module.
    _zero_pad:
        rep #0x20
        lda.w #0x0000
        rts
}

Symbols inside .scope vwf { ... } export as vwf.init. The leading _ on _zero_pad keeps it LOCAL to the module — other modules cannot reference it through the linker.

The entrypoint pulls it in

src/main.s:

"""Top-level patch."""

.import "vwf"

*= 0x008000
main:
    """Entry — run the VWF init then loop."""
    jsl vwf.init
    bra main

.import "vwf" resolves in this order:

  1. vwf.o in --obj-dir (default build/obj).
  2. vwf.s on a search path (-I / --module-path or the same directory).

When only .s is available, the build driver compiles to .o first on the fly.

Build

A single command does compile + link with auto-imports:

$ a816 build src/main.s -o build/patch.ips

If you want to inspect the intermediates:

$ a816 build --compile-only src/modules/vwf.s
$ xobj --sections --symbols src/modules/vwf.o

Cross-module references

When module A .imports module B, every runtime symbol B exports (GLOBAL labels, alloc names, .incbin auto-symbols) is automatically available as an extern in A. No explicit .extern needed — the per-node import classifier emits the extern stubs from B's .o and inlines B's compile-time content (structs, macros, typed binds, pool decls) for codegen.

.import "vwf"

main:
    jsr.w vwf.init   ; resolves through the auto-extern
    rts

.extern name is still useful for symbols you don't want to import the owning module for — build-script-injected constants, third-party .o drops, or sub-symbols of a .label-declared name that the auto-classifier doesn't reach.

The linker verifies every extern resolves to a definition and fails the build (with a useful message) if any are missing.

Constants over externs

You can compute compile-time constants from external symbols. The expression is recorded in the .o's alias table and resolved at link time:

.extern target

font_ptr  = target + 0x40
font_high = (target >> 16) & 0xFF

Caveats: the alias's expression has to evaluate to a constant at link time. Macros that reference externals via source >> 16 generally work; constant assignments that the assembler tries to fold into a single literal during compile (e.g., font_ptr := assets_menu_font_dat) don't, so use the external symbol directly in the instruction.

Preamble

Put shared compile-time material (feature flags, .table defaults, pool decls, typed binds, structs) in a .s file and import it explicitly from each entry point:

.import "preamble"

The inline classifier picks up the preamble's compile-time symbols; runtime symbols become externs the linker resolves.

Lint as you go

$ a816 check src/

The relevant rules for module work:

  • DOC001 — every module needs a leading docstring.
  • DOC002 — public macros / scopes / labels need attached docs.
  • DOC003 — docstrings sitting outside their target's body get flagged. Move them inside { ... } or above the label.

See Fluff (lint + format) for the full rule set and the ; noqa suppression syntax.