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. The project is in the repository under
docs/examples/modules-walkthrough/; the test suite builds it and
checks the patch, so what you read here is what assembles.
Starting layout¶
a816.toml names the entry file, where .import looks, and the
cartridge (a 512 KB LoROM board):
A reusable module¶
src/modules/vwf.s exports a small variable-width-font init routine:
"""VWF helpers shared across the hack."""
.alloc vwf_code at 0x018000 {
.scope vwf {
init:
"""Set the VRAM increment mode used by the VWF renderer."""
lda.b #0x80
sta.w 0x2115
rtl
; private, only callable from inside this module
_zero_pad:
rep #0x20
lda.w #0x0000
rts
}
}
The module owns its placement: .alloc vwf_code at 0x018000 { ... }
pins its bytes (use .alloc vwf_code in POOL { ... } to let the
linker pick the address instead). Code left outside any .alloc /
*= is rejected with E0310; see Placement.
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"
.alloc boot at 0x008000 {
main:
"""Entry: run the VWF init, then loop."""
jsl vwf.init
bra main
}
.import sits in the file prelude, before any placement; inside an
.alloc or after a *= it is E0311. Importing a module does not
place it: vwf.s owns its address through its own .alloc.
.import "vwf" resolves vwf.o in --obj-dir (default build/obj)
or vwf.s on the module paths (module-paths in a816.toml, -I on
the command line), compiling it first when only the source exists. The
importing file's own directory is not searched: a module in a
subdirectory is imported by its path (.import "ingame/items").
Build¶
A single command does compile + link with auto-imports:
The patch has two records: the entry at file offset 0x000000
(jsl $01:8000 then bra main) and the VWF routine at 0x008000,
bank $01 of the LoROM image.
If you want to inspect the intermediates:
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.
main.s above does exactly that: jsl vwf.init resolves through the
extern stub the import created.
.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:
The expression may use any operator; the linker evaluates it once
target has its address. := works too, typed views
(view := (target as T)) included: a module importing this one takes
the name from its object instead of evaluating it.
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:
The inline classifier picks up the preamble's compile-time symbols; runtime symbols become externs the linker resolves.
Lint as you go¶
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.