nessemble
nessemble is a 6502 assembler targeting the Nintendo Entertainment System
(NES), written in Rust.
Upgrading from a 1.x release? See Upgrading for what changed in 2.0.
Getting Started
To initialize a new project:
nessemble init
Build the project:
nessemble project.asm --output project.nes --format nes
Run project.nes in any NES emulator to see the result.
Documentation
Start here: Installation.
- Usage — the command-line interface.
- Syntax — the assembly language reference.
- Extending — custom pseudo-instructions with Rhai.
- Building — building from source.
- Translating — adding a locale.
Installation
Download and install the latest release for your system:
https://github.com/kevinselwyn/nessemble-rs/releases
Release artifacts are provided for all five supported platforms:
| Platform | Artifact(s) |
|---|---|
| macOS | nessemble_<v>.pkg, nessemble_<v>_macos.tar.gz |
| Linux amd64 | nessemble_<v>_amd64.deb |
| Linux i386 | nessemble_<v>_i386.deb |
| Windows 32-bit | nessemble_<v>_win32.exe, …_win32.msi |
| Windows 64-bit | nessemble_<v>_win64.exe, …_win64.msi |
Two more artifacts accompany every release:
nessemble_<v>.vsix— the VS Code extension.nessemble_<v>_wasm.tar.gz— the in-browser assembler bundle (the WebAssembly build plus the<nessemble-assembler>web component).
A linux/amd64 container image is also published to GHCR for
CI pipelines and coding agents that only need the executable.
VS Code extension
nessemble_<v>.vsix is the VS Code (and Cursor) extension: a client for the
assembler's built-in language server, giving you diagnostics, lint
hints, completion, hover, formatting, semantic highlighting, and
go-to-definition on .asm/.s files.
It contains no copy of the assembler, so install nessemble itself first —
the extension runs the nessemble on your PATH. One universal .vsix works
on every platform:
code --install-extension nessemble_<v>.vsix
Or use the Extensions view's Install from VSIX… command. See Editor support for settings and format-on-save.
macOS: "Apple could not verify…"
The macOS .pkg is not signed with an Apple Developer ID or notarized, so after
you download it, Gatekeeper blocks it with:
"nessemble_<v>.pkg" Not Opened — Apple could not verify "nessemble_<v>.pkg" is free of malware…
This is expected for an unsigned package; it does not mean the file is harmful. You have two options.
Install the .pkg anyway — clear the download-quarantine flag, then install:
xattr -d com.apple.quarantine nessemble_<v>.pkg
sudo installer -pkg nessemble_<v>.pkg -target /
Or use the plain binary tarball (nessemble_<v>_macos.tar.gz) and skip the
installer:
tar -xzf nessemble_<v>_macos.tar.gz
xattr -d com.apple.quarantine nessemble # clear quarantine
sudo mv nessemble /usr/local/bin/ # put it on your PATH
Both install the same binary to /usr/local/bin/nessemble. (The tarball binary
is a 64-bit Intel build, matching the .pkg; it runs on Apple Silicon under
Rosetta.)
Container image
A binary-only image is published to the GitHub Container Registry on every release:
ghcr.io/kevinselwyn/nessemble-rs:<version> # e.g. :2.11.0
ghcr.io/kevinselwyn/nessemble-rs:latest
It exists for CI pipelines and coding agents that need the nessemble
executable but not this source tree — pulling the image is faster and simpler
than building the workspace. The image is a single statically-linked
linux/amd64 binary on scratch: there is no shell, package manager, or libc
inside it, only /nessemble. That means you consume it by lifting the binary
out, not by opening a shell in it.
Copy it into your own image with a multi-stage COPY --from — the most
common pattern for build tools:
COPY --from=ghcr.io/kevinselwyn/nessemble-rs:2.11.0 /nessemble /usr/local/bin/nessemble
Extract it to the host without writing a Dockerfile — create a container from the image (it need not run) and copy the file out:
docker create --name nessemble ghcr.io/kevinselwyn/nessemble-rs:2.11.0
docker cp nessemble:/nessemble ./nessemble
docker rm nessemble
Run it directly — the entrypoint is the binary, so pass nessemble
arguments straight through and mount your project as the working directory:
docker run --rm ghcr.io/kevinselwyn/nessemble-rs:2.11.0 --version
docker run --rm -v "$PWD:/work" -w /work \
ghcr.io/kevinselwyn/nessemble-rs:2.11.0 project.asm --output project.nes --format nes
Pin a version tag (:2.11.0) rather than :latest for reproducible builds. In
a Claude Code on the web (or similar) environment, pulling the image in a setup
script caches it into the environment snapshot, so the binary is on disk at the
start of every later session.
From source
nessemble is a Cargo workspace and builds with a stock Rust toolchain.
git clone https://github.com/kevinselwyn/nessemble-rs
cd nessemble-rs
cargo build --release
The binary is written to target/release/nessemble. See
Building for cross-compilation and packaging details.
Upgrading from 1.x to 2.x
nessemble 2.0 is a ground-up rewrite in Rust. Assembly source and ROM output
are compatible — the same .asm files assemble to the same bytes — but the tool
around them changed. This page covers what a 1.x user needs to know.
Assembly & ROM output
- No changes needed to your source. The assembly language (instructions, addressing modes, expressions, labels, macros, conditionals, includes, data and iNES directives, media importers) is unchanged, and assembled ROMs are byte-for-byte identical to 1.x output. The one behavioral change is how relative filenames are resolved — see Include & asset paths.
Include & asset paths
- In 1.x, every filename-based directive resolved its path against a single global working directory (the top-level file's directory). A relative path in an included file was therefore resolved from the project root, not from the included file.
- In 2.x, relative filenames in
.include,.inestrn, and the.inc*media importers (.incbin,.incpng,.incpal,.incrle,.incwav) resolve relative to the directory of the file that contains the directive. This makes subdirectory modules self-contained: a file insub/that does.include "helper.asm"or.incbin "data.bin"now finds them insub/. - What to check: if a file you
.includefrom a subdirectory referenced a sibling file or asset by a path written relative to the project root, update that path to be relative to the including file instead. Projects that keep each file's includes and assets alongside it need no changes.
Custom pseudo-instructions
- The three embedded scripting engines (JavaScript, Lua, and Scheme) and native
shared-object (
.so/.dll) plugins are replaced by a single embedded language, Rhai. - Rewrite custom scripts as
.rhaifiles and update your--pseudomapping to point at them. A script now definesfn custom(ints, texts)and returns the emitted bytes. See Extending. - The bundled
easescript is provided as.rhai; runnessemble scriptsto install it. - Rhai scripts can still read and write files (as the old Lua/Scheme hosts
could), via the
rhai-fsopen_fileAPI; relative paths resolve against the source file's directory. See Filesystem access. - Script paths in a
--pseudomapping now resolve relative to the mapping file's own directory, not the source file's directory. In 1.x the paths were resolved against the input file's location (the tool'scwd_path). If yourpseudo.txtsits next to the source you assemble, nothing changes; otherwise, keep each script beside the mapping file that names it (or update the paths).
Removed commands and options
The following 1.x features are not part of 2.x — they are not parsed and do
not appear in --help:
- The disassembler / reassembler (
-d/--disassemble,-R/--reassemble). - The simulator / debugger (
-s/--simulate,-r/--recipe). - The package registry:
registry,install,uninstall,publish,info,ls,search, and the user/auth commands (adduser,login,logout,forgotpassword,resetpassword).
config remains, but is now a general key/value store (the registry key it used
to manage is gone).
Internationalization
- Translations moved from gettext (
.po/.mo) to Project Fluent. Drop a~/.nessemble/locales/<lang>.ftlfile and select it withNESSEMBLE_LANG. See Translating.
Building & installing
- Building no longer needs a C toolchain, flex/bison, or gettext — just a Rust toolchain. See Building.
- Release artifacts (
.deb,.msi,.pkg, and standalone.exe) are provided for the same platforms as before. See Installation.
Usage
nessemble is driven from the command line. This page documents its options and
subcommands.
Usage: nessemble [OPTIONS] [infile.asm]
nessemble <COMMAND>
Commands:
init initialize new project
scripts install scripts
reference get reference info about assembly terms
lsp run the language server (stdio)
format format assembly source
lint lint assembly source
coverage report runtime coverage from a CDL capture
help Print this message or the help of the given subcommand(s)
Arguments:
[infile.asm] assembly source to assemble (stdin if omitted)
Options:
-o, --output <outfile.rom> output file
-f, --format <NES|RAW> output format
-e, --empty <hex> empty byte value
-u, --undocumented use undocumented opcodes
-l, --list <listfile.txt> generate list of labels and constants
--mlist include labels created by macros in the list file
-p, --pseudo <pseudo.txt> use custom pseudo-instruction functions
--root <dir> project root for `@/`-relative paths
--max-operations <n> cap each pseudo-op script's Rhai operation count
--time-scripts report per-directive script call count and wall time
-c, --check check syntax only
-v, --version display program version
-L, --license display program license
-h, --help Print help
The help text above is generated by clap; run
nessemble --help (or nessemble <command> --help) for the current output.
The lsp command starts the built-in Language Server for use with
LSP-capable editors. The format command reformats assembly source (§
format) and the lint command reports style problems
without rewriting it (§ lint). The coverage command reports
coverage from an emulator CDL capture, the -p Rhai scripts, or both (§
coverage).
Options
-o, --output <outfile.rom>
Sets the filename where output is written. An outfile of - (or omitting the
flag) writes to stdout.
nessemble infile.asm --output outfile.rom
-f, --format
Specifies the output format:
NES— an iNES ROM, complete with a 16-byte header.RAW— raw assembled 6502 code.
The format is RAW by default, but if iNES header directives (.inesprg,
.ineschr, …) are present, it becomes NES unless overridden.
-e, --empty <hex>
Sets the fill value for empty/unwritten ROM bytes. Defaults to FF.
nessemble infile.asm --empty 00
-u, --undocumented
Allows the use of undocumented ("illegal") opcodes.
-l, --list <listfile.txt>
Writes a list of labels and constants to the given file.
Labels defined inside a macro body are omitted by default, so the list is
not cluttered with the per-invocation labels a macro emits each time it is called
(for example a loop target uniquified with \@). Pass --mlist to include them.
--mlist
Includes labels created by macro expansion in the --list output. Has no effect
unless --list is also given.
nessemble infile.asm --list listing.txt --mlist
Constants defined in macros always appear in the list; --mlist affects only the
[labels] section. See Macros for more on macro-defined
labels.
-p, --pseudo <pseudo.txt>
Points to a mapping file that enables custom pseudo-instructions. See Extending.
--root <dir>
Sets the project root that @/-prefixed
filename arguments resolve against, overriding the usual walk-up for the nearest
.nessemblerc, .nessemblerc.json, or .nessembleignore.
nessemble src/main.asm --root .
dir must exist; a missing or non-directory path is rejected before assembly
starts.
--no-cache
Neither reads nor writes the custom pseudo-instruction cache: every directive's script runs. Reach for this when a build looks stale, or to confirm that a suspected caching problem is one.
--max-operations <n>
Overrides the runaway-script guard
on every pseudo-op script, in place of the built-in default of 10,000,000
Rhai operations. 0 means unlimited.
nessemble src/main.asm --pseudo pseudo.txt --max-operations 50000000
--time-scripts
Prints a per-directive report to stderr after assembly: how many times each
.foo-style directive was called, how many of those were cache hits versus
real script runs, and the total wall time spent in it — busiest directive
first. See Runaway-script guard and
timing.
-c, --check
Checks the input for syntax errors only; produces no output.
-v, --version / -L, --license / -h, --help
Print the version, license, or usage message respectively.
Commands
init [<arg> ...]
Scaffolds a new project, prompting for any values not supplied as arguments:
nessemble init [filename] [prg] [chr] [mapper] [mirroring]
filename— file to create.prg/chr— number of PRG / CHR banks.mapper/mirroring— iNES mapper and mirroring.
scripts
Installs the bundled custom-pseudo-instruction scripts into
~/.nessemble/scripts. See Extending.
cache info | cache clear
Inspects or empties the custom pseudo-instruction
cache in ~/.nessemble/cache.
nessemble cache info # where the cache is, how many entries, how many bytes
nessemble cache clear # delete every entry
Clearing is always safe: an entry is a saved result, never the only copy of anything, so the next build recomputes what it needs.
reference [<category>] [<term>]
Prints reference information from locally bundled data. With no arguments it
lists the categories (instructions, directives, script); with a category it
lists its entries; with a term it prints the details (e.g. reference instructions LDA).
The script category is the host API a custom pseudo-op
script can call, grouped by domain:
nessemble reference script # every function, method, and property
nessemble reference script nes_shade # signature, summary, and a docs link
A name catalogued more than once prints each meaning — read_blob is both a
method on an open file handle and a one-call function — and an entry that needs
a build feature says so, since the WebAssembly build has neither
filesystem access nor random
numbers.
format [<opt> ...] <path> ...
Reformats nessemble assembly source in an opinionated, Prettier-style
way: consistent indentation and comma spacing, .db/.dw/.color data
consolidated a fixed number of values per line, a blank line after each
RTS/RTI, collapsed runs of blank lines, and a normalized final newline.
Formatting is cosmetic only — the assembled ROM is never changed.
nessemble format path/to/file.asm # print formatted source to stdout
nessemble format --write path/to/dir # rewrite files in place
nessemble format --check path/to/dir # CI gate: exit non-zero if unformatted
- A single file with no flags prints the formatted result to
stdout, leaving the file untouched. -w,--writerewrites each changed file in place and prints its path.-c,--checkwrites nothing; it lists files that are not already formatted and exits non-zero — the gate for CI.- A directory is walked recursively (for the configured extensions,
.asmby default) and requires--writeor--check. --config <file>uses<file>as the.nessemblerc;--no-configignores any.nessemblercand uses built-in defaults.
The editor Language Server's "format document" action runs this same formatter, so editors and the CLI produce identical output.
lint [<opt> ...] <path> ...
Reports style problems in nessemble assembly. Where format is Prettier, lint
is ESLint: it only reports and never rewrites source.
nessemble lint path/to/file.asm # report problems for one file
nessemble lint path/to/dir # walk a tree and report problems
src/prg/07.asm
42:1 warning code block `sound_engine` has no nearby comment require-block-comment
103:1 warning unknown comment directive `@nessemble-formt` unknown-comment-directive
✖ 2 problems (0 errors, 2 warnings)
The rules:
| Rule | Flags |
|---|---|
require-block-comment | A block-opening label with no comment nearby — a named label whose preceding non-comment line is blank or the top of the file. Internal branch targets and anonymous labels are never flagged. |
unknown-comment-directive | A comment directive naming no known directive (@nessemble-formt), or a known one with wrong arguments (@nessemble-format stride=x). |
deprecated-comment-directive | A directive written with a deprecated alias — today, @fmt for @nessemble-format. |
ineffective-comment-directive | A well-formed directive that cannot apply where it is written: in a trailing comment, a -next-line with no following line, a stride hint with no data run after it, or an unbalanced ignore-region bound. (An unclosed region is not flagged — that is the documented whole-file opt-out.) |
invalid-routine-signature | A routine annotation that binds to no label (code intervenes, or the file ends), or names the same slot twice. |
undeclared-clobber | A routine that writes a register its @nessemble-clobbers omits — the annotation and the code disagree. Only routines that declared a clobber list are checked. |
overdeclared-clobber | A routine that declares a clobber its body cannot produce. Fires only when the body holds no unknowns (no macro invocation, unannotated call, indirect jump, or data run). |
require-routine-doc | A called routine with no annotations at all. Off by default — switch it on per project once you are using the convention. |
- Each finding prints as
LINE:COL severity message rule-id, grouped by file, with a problem-count footer; a clean run prints✓ No problems. - Exit code: any
error-severity finding fails the run (exit non-zero);warnfindings do not, unless--max-warnings <n>is exceeded. This is the CI gate. - A directory is walked recursively (for the configured extensions,
.asmby default), skipping.nessembleignorepaths. --quietreports errors only (suppresses warnings).--config <file>uses<file>as the.nessemblerc;--no-configignores any.nessemblercand uses built-in defaults.
Every rule defaults to warn except require-routine-doc, which defaults to
off. Rules, their severities, the comment window, and the label-name ignore
list are configured under the lint key of .nessemblerc. The editor
Language Server surfaces the same findings inline as you type,
including quick fixes for the deprecated directive spelling and for a register
missing from a clobber list.
Suppressing a finding
Two directives silence findings at the site, for when a rule is wrong about a particular routine:
; @nessemble-lint-ignore-next-line [rule[, rule...]]
; @nessemble-lint-ignore start|end [rule[, rule...]]
Bare, they suppress every rule. With a comma-separated list of rule ids, only
those. An unknown rule name is reported as
unknown-comment-directive, so a typo cannot silently
silence nothing.
These exist because the clobber analysis has two known blind spots, both of which force an author to choose between a false annotation and a false finding. Suppressing at the site is the third, better option:
Fall-through entry points. A routine's body is read as its own block plus
the JSR/JMP targets it names; a routine that falls through into the next one
is not followed. So assign_chr_bank below really does clobber A, X, Y — via
the routine it falls into — but its own block writes no register, and
overdeclared-clobber fires:
; @nessemble-lint-ignore-next-line overdeclared-clobber
; @nessemble-clobbers A, X, Y, [chr_bank_arg]
assign_chr_bank:
STA <chr_bank_arg
; falls through ↓
; @nessemble-clobbers A, X, Y
find_or_evict_chr_slot:
...
Save/restore pairs. A PHA … PLA around a routine's body preserves A,
but the restore is not modeled, so undeclared-clobber asks you to declare A
clobbered — inverting what the tag means:
; @nessemble-lint-ignore-next-line undeclared-clobber
; @nessemble-clobbers X, Y, [draw_tmp]
ppu_set_xy_addr:
PHA
...
PLA
RTS
In both cases the annotation stays truthful — it is what a caller needs to know — and exactly one bogus finding is suppressed.
The rules are the coverage directives' rules, because it is the same mechanism:
-next-linetargets the next significant line, skipping blank and comment lines. That is what lets it sit above a whole@nessemble-paramblock and still land on the label, which is where the routine rules report.startopens a region andendcloses it; an unclosed region runs to end of file (the whole-file opt-out), regions do not nest, and a region never crosses into an.included file.- A directive in a trailing comment is inert, and reported.
- Matching is by the line a finding is reported at. Most rules report at the
label — including
undeclared-clobber,overdeclared-clobber, andrequire-block-comment— so-next-lineabove the label reaches them.invalid-routine-signatureand the comment-directive rules report at the annotation line instead, so use the region form to suppress those. - A suppressed finding is gone, not downgraded: it does not print, does not
count toward
--max-warnings, and does not affect the exit code, even when the rule's configured severity iserror. Severity is your choice in.nessemblerc, so it is not a reason to withhold the escape hatch. - Suppression applies to lint findings only. It never silences a parse or assembly error.
coverage <infile.asm> --cdl <file.cdl> ...
Reports runtime execution coverage of an assembled ROM against a CDL
(Code/Data Logger) capture an emulator wrote after running the ROM, line
coverage for the -p Rhai scripts a build runs, or both. It never writes a
ROM.
nessemble coverage main.asm --cdl capture.cdl # coverage.json + coverage.lcov
nessemble coverage main.asm --cdl capture.cdl --format lcov --out cov.lcov
nessemble coverage main.asm --cdl a.cdl --cdl b.cdl --emulator mesen
nessemble coverage main.asm -p pseudo.txt --scripts # scripts only, no CDL, no emulator
At least one of --cdl or --scripts is required.
With --cdl, the source assembles with a byte-exact source map and each
PRG-emitting line is classified as code (executed), data (read),
mixed (both), or unaccessed (present but never touched). Only the PRG
section is classified; lines that emit only CHR data are omitted.
-
--cdl <file>— the CDL to read; repeatable (multiple files are merged by bitwise OR). Required unless--scriptsis given. -
--emulator <fceux|mesen>— the CDL format, defaultfceux. FCEUX and Mesen flat masks are the same size but bit-incompatible, so there is no auto-detect; state the emulator that produced the file. (BizHawk's container format is not yet supported.) -
--format <json|lcov|all>— report format, defaultall. JSON carries the full four-way class per line, plus a"kind"of"rom"or"script"per file; LCOV is line hit/not-hit for coverage tools and has nokind. -
--out <path>— output file for a single format, or a directory forall(coverage.json+coverage.lcov); defaults to the current directory. -
-p,--pseudo <pseudo.txt>— custom pseudo-op mapping, as in assemble mode. -
--root <dir>— project root for@/-relative paths, as in assemble mode. -
--max-operations <n>— caps each pseudo-op script's Rhai operation count, as in assemble mode. -
--scripts— also report line coverage for the-pRhai scripts, so you can see which parts of a custom pseudo-op never ran during assembly. Each project script appears as its own file (each line executed or not); bundled~/.nessemblescripts are excluded. Available when the binary is built with thecoveragefeature (on by default).Given without
--cdl, this reports scripts alone: the source assembles without forcing NES mode or a source map, no CDL is read, and no--emulatoris consulted — the shape a CI job without an emulator playthrough can produce. Every script the-pmapping names is included, even one whose directive the build never reaches (reported at 0%, not omitted); a script mapped under two directive names appears once. A run that instruments no script at all — no-pmapping given, or the mapping named nothing readable — prints a warning saying why instead of a silent, unscripted report. -
--no-ignore— report every line, disabling the@nessemble-coverage-ignore…directives.
Source can exclude lines it does not want measured with the coverage ignore directives; excluded lines leave both the numerator and the denominator, and the run reports how many were dropped:
coverage: 812/900 lines (90.2%) — 14 lines, 1 file ignored
When both --cdl and --scripts contribute files, the single percentage above
can move for reasons that have nothing to do with the ROM, so the summary also
splits the two:
coverage: 812/900 lines (90.2%) — rom 780/840, scripts 32/60
The CDL must be the same size as this ROM's PRG+CHR (it carries no ROM identity of its own); a size mismatch is a hard error. Equal sizes still do not guarantee the CDL came from this exact build, so capture it from the ROM this source assembles to.
Comment directives
Some nessemble tools take instructions from the source itself, written as comment directives: a comment whose first token names the tool and what to do.
; @nessemble-<name> [args] [; trailing prose]
| Directive | Applies to | Tool |
|---|---|---|
@nessemble-format stride=N[,N,...] | the next data run (skipping blank, comment, and label lines) | format |
@nessemble-coverage-ignore-next-line | the next significant line | coverage |
@nessemble-coverage-ignore start | end | every line between the two | coverage |
@nessemble-param <slot> [description] | the routine below — a register it reads | lint, editor |
@nessemble-returns <slot> [description] | the routine below — a slot it defines | lint, editor |
@nessemble-clobbers <slot>[, ...] | none | the routine below — what it destroys | lint, editor |
@nessemble-lint-ignore-next-line [rule[, ...]] | findings on the next significant line | lint |
@nessemble-lint-ignore start | end [rule[, ...]] | findings on every line between the two | lint |
@fmt stride=N[,N,...] | deprecated alias of @nessemble-format | format |
The rules are the same for every directive:
The routine annotations are described in Documenting routines; the rules below apply to every directive.
- It must be on its own line — a directive in a trailing comment
(
LDA #$00 ; @nessemble-…) does nothing, and is reported. - The
@…token must come first in the comment, after the;(or;;,;;;) and any spaces. A directive mentioned mid-sentence is prose. - Names are exact and lower-case; anything after the arguments and a second
;is free-text prose. - A directive applies to what follows it, and blank lines, comment lines, and label or constant definitions in between are skipped — so a directive can sit above the label that names its subject, and an explanation can follow the directive.
- An unrecognized
@nessemble-…name, or a known one with wrong arguments, is reported bylintand in the editor rather than silently ignored — that is what the namespace buys you. Ordinary@-comments (; @todo,; @param) are never touched.
Excluding lines from coverage
Two directives keep lines out of a coverage
report. Excluded lines leave both sides of the ratio — they are not counted
as covered, and not counted at all — so the percentage reflects only the lines
you meant to measure.
@nessemble-coverage-ignore-next-line excludes the next significant line;
blank and comment lines in between are skipped, so an explanation can follow the
directive:
; @nessemble-coverage-ignore-next-line
; only reachable from a mapper IRQ we can't trigger in CI
JMP nmi_stub
@nessemble-coverage-ignore start … end excludes a whole region:
; @nessemble-coverage-ignore start
mapper3_init:
LDA #$00
STA mapper_reg
; @nessemble-coverage-ignore end
- An unclosed region runs to the end of the file — put a lone
; @nessemble-coverage-ignore startin the header to opt a whole file out. A file with nothing left to report is dropped from the report entirely (noSF:record) and counted as an ignored file. - Regions are per file: one does not extend into an
.included file, and each included file carries its own directives. - Regions do not nest. A
startinside an open region, or anendwith nostart, does nothing and is reported bylint. - Rhai scripts (under
--scripts) honor both directives, written as//comments. nessemble coverage --no-ignorereports every line regardless — useful in CI, or to see what the directives are hiding. The stdout summary always names how much was excluded, and the JSON report carriesignored/ignoredFilescounts.
Documenting routines
The 6502 has no calling convention: every routine invents its own, and the only record of it is usually a comment, if that. Three directives write that convention down where the tools can read it — which registers a routine takes, what it hands back, and what it destroys:
; Draw one metasprite from `metasprite_table` into the OAM shadow buffer.
;
; @nessemble-param A metasprite index into `metasprite_table`
; @nessemble-param X screen x, in pixels
; @nessemble-param Y screen y, in pixels
; @nessemble-returns C set when the sprite was clipped off-screen
; @nessemble-clobbers A, X, Y, [oam_cursor]
draw_metasprite:
...
RTS
Hovering any use of draw_metasprite — including the operand of a JSR,
in this file or another open one — shows that table, so "does this call eat my
Y?" is answered without leaving the call site.
Slots. A slot is a place a value lives across a call. The vocabulary is closed, so a typo is reported rather than silently accepted:
| Slot | Meaning |
|---|---|
A, X, Y | the registers |
S | the stack pointer |
P | the whole status register |
C, Z, N, V, D, I | one flag — @nessemble-returns C is how a 6502 routine returns a boolean |
[symbol] | a named memory location, e.g. [oam_cursor] |
$NN, $NNNN, $NN-$NN | an address or inclusive range, e.g. $10-$1F |
none | (clobbers only) preserves everything |
Slot names are case-insensitive (a and A both work) and always render
upper-case. Memory needs its brackets or its $: that is what lets
@nessemble-clobbers AX be reported as a bad slot instead of quietly becoming a
symbol named AX.
What the tags mean.
@nessemble-paramand@nessemble-returnstake one slot each, followed by a free-text description (a;in it is part of the description). Repeat the tag for each slot.@nessemble-clobberstakes a list, and means anything not listed is preserved. A returned slot is clobbered by definition and need not be repeated.@nessemble-clobbers noneclaims the routine preserves everything. That is different from writing no@nessemble-clobbersat all, which claims nothing.- Annotations bind to the first label below them; blank lines and prose comments in between are skipped, so a summary can sit above the tags. A line of code in between binds them to nothing, and is reported.
The declaration is checked. nessemble lint compares each declared clobber
list against what the routine actually writes:
src/prg/sprite.asm
42:1 warning routine `draw_metasprite` writes Y but does not declare it clobbered undeclared-clobber
- Only
A,X,Y, andSare verified. Flags are documentation — nearly every instruction disturbsN/Z, so checking them would flag everything. Memory slots are documentation too. - Only routines that declared a clobber list are checked, so a project adopts this one routine at a time.
- A routine's body runs from its label to the next block-opening label. A call to
another annotated routine contributes that routine's declared clobbers; a
call to an unannotated one, a macro invocation, an indirect jump, or a data run
inside the body makes the body "not fully understood", which silences
overdeclared-clobberbut neverundeclared-clobber. - A routine that falls through into the next one is under-reported rather than
over-reported: the analysis prefers a missed warning to a wrong one. Where that
costs a wrong finding — a fall-through entry point, or a
PHA/PLApair the analysis does not model — suppress it at the site with@nessemble-lint-ignore…and keep the annotation truthful. - The stack pointer counts as clobbered only for
TXS. Pushes and pulls moveS, but routines balance them.
.nessemblerc
Formatting is configurable, Prettier-style, via an optional .nessemblerc (or
.nessemblerc.json) file discovered by walking up from the file or directory
being formatted. It is JSON; every key is optional and takes the default shown
below, so a project with no .nessemblerc still gets fully-formatted output.
Unknown keys are rejected (to catch typos early).
{
"extensions": [".asm"],
"indentStyle": "space",
"indentWidth": 4,
"commaSpacing": true,
"finalNewline": true,
"indentDirectives": false,
"alignContinuations": true,
"dataPerLine": 8,
"respectStrideHints": true,
"blankLineAfterReturn": true,
"maxConsecutiveBlankLines": 2,
"mnemonicCase": "preserve",
"hexDigitCase": "preserve",
"overrides": []
}
| Key | Default | Meaning |
|---|---|---|
extensions | [".asm"] | File extensions formatted during a directory walk. |
indentStyle | "space" | Instruction indent: "space" or "tab". |
indentWidth | 4 | Spaces per indent level (space style only). |
commaSpacing | true | ", " between values; false for tight commas. |
finalNewline | true | Ensure the file ends in exactly one newline. |
indentDirectives | false | Indent directive lines (.db, .dw, .include, …) to block depth like instructions. false pins them to column 0 (house style); true suits codebases that indent data under labels. Labels and constants stay at column 0 either way. |
alignContinuations | true | Align the continuation lines of a multi-line statement (operands wrapped onto the next line by a trailing comma) under the opening line's first argument. false indents them to the block indent (indentWidth). See below. |
dataPerLine | 8 | Values per consolidated .db/.dw/.color line; 0 disables consolidation. |
respectStrideHints | true | Honor ; @fmt stride=N[,N,...] comments (see below). |
blankLineAfterReturn | true | Insert one blank line after every RTS/RTI. |
maxConsecutiveBlankLines | 2 | Collapse longer runs of blank lines down to this. |
mnemonicCase | "preserve" | Case the instruction mnemonic: "preserve", "lower", or "upper". |
hexDigitCase | "preserve" | Case hex-digit letters ($ab vs $AB): "preserve", "lower", or "upper". |
overrides | [] | Per-glob option overrides (see below). |
Directive names (.db, .DB) are never re-cased — nessemble is case-sensitive
about them.
Stride hints
To override dataPerLine for one data block, place a
; @nessemble-format stride=N comment directive
before it. Multiple strides cycle in order and the last one repeats:
; @nessemble-format stride=2
.db $01, $02
.db $03, $04
The hint binds to the next data run, skipping blank lines, comment lines, and label or constant definitions on the way — the run's own label and an explanatory comment are both transparent:
; @nessemble-format stride=3
; one row of the tile per line
palette_rows:
.db $01, $02, $03
.db $04, $05, $06
If the first thing that is not one of those is anything but a .db/.dw/
.color line, the hint applies to nothing and
lint reports it as ineffective-comment-directive.
format and lint resolve the target the same way, so a hint the formatter
honors is exactly a hint the linter calls effective.
Deprecated spelling. The original
; @fmt stride=Nstill works and always will — it is an alias, not a removal. It is reported by thedeprecated-comment-directiverule, and the editor offers a one-click rename. To migrate a tree in bulk:grep -rl '@fmt' --include='*.asm' . | xargs sed -i 's/@fmt/@nessemble-format/g'
Continuation alignment
When a statement's operand list wraps onto further lines (a trailing comma
continues it onto the next physical line), alignContinuations (on by default)
lines up each continuation under the opening line's first argument:
.metasprite $FA, $02, $00, $FA,
$FA, $03, $00, $02,
$02, $0D, $00, $FA
With alignContinuations: false, continuation lines fall to the block indent
instead:
.metasprite $FA, $02, $00, $FA,
$FA, $03, $00, $02,
$02, $0D, $00, $FA
The alignment is computed from the opening line's actual indent, so it stays
correct together with indentDirectives. Under indentStyle: "tab" the
continuation reuses the opening line's leading tab and then pads to the
first-argument column with spaces. Only leading whitespace changes, so the
assembled output is unaffected.
Overrides
overrides is an ordered list of { "files": <glob>, "options": { … } }
entries; for each formatted file, later matching entries layer their options on
top of the base config. Globs support *, **, and ?.
{
"dataPerLine": 8,
"overrides": [
{ "files": "src/data/**/*.asm", "options": { "dataPerLine": 16 } }
]
}
.nessembleignore
A .nessembleignore file (gitignore-style globs, one per line) excludes matching
paths from directory walks. It is discovered the same way as .nessemblerc.
lint
The lint subcommand is configured under a lint key in the
same .nessemblerc. With no config the linter is on with its defaults (every
rule at "warn" except require-routine-doc, which is "off"; a comment window
of 3; and no ignores), so nessemble lint is useful out of the box.
{
"lint": {
"rules": {
"require-block-comment": ["warn", { "window": 3 }],
"unknown-comment-directive": "error",
"deprecated-comment-directive": "off",
"require-routine-doc": "warn"
},
"ignore": ["^loc_[0-9A-Fa-f]", "^data_[0-9A-Fa-f]"]
}
}
| Key | Default | Meaning |
|---|---|---|
rules | {} | Per-rule severity. Each value is "off", "warn", or "error", or a [severity, { …options }] pair. Unknown rule names are rejected. |
ignore | [] | Regexes matched against a label's name; a match exempts it from every rule. Anchors (^) are yours to add. |
Severity. A rule at "off" is not run. An "error" finding fails the run
(non-zero exit); a "warn" finding does not, unless the CLI's --max-warnings
is exceeded. In the editor, findings appear at a gentle severity (Information for
error, Hint for warn) with the nessemble-lint source, distinct from the
assembler's own errors and warnings.
Rule options. require-block-comment takes a window (default 3): a block
label is clean if any line within ±window lines carries a comment. The
directive and routine-signature rules take no options.
Ignore patterns. List the name shapes that should never be flagged — most
often machine-generated disassembly labels (loc_8000:, data_c123:). No
patterns ship by default, so a fresh project flags every undocumented block until
you opt specific shapes out. (Ignore patterns match label names, so they do not
affect the comment-directive rules; silence those with a
severity of "off". They do apply to the routine rules, which are keyed by the
routine's label name.)
Per-glob overrides may carry a lint block too, so a data-heavy
directory can loosen or disable a rule for its files:
{
"overrides": [
{ "files": "src/data/**/*.asm",
"options": { "lint": { "rules": { "require-block-comment": "off" } } } }
]
}
Syntax
The examples on this page are interactive: edit the source and click Assemble to build it in your browser (powered by the WebAssembly build of nessemble). Assembled bytes are shown as a hex dump and can be downloaded.
Numbers
Binary, decimal, octal, hexadecimal, and ASCII character are all valid numbers.
| Base | Example A | Example B |
|---|---|---|
| Binary | %01000001 | 01000001b |
| Decimal | 65 | 65d |
| Octal | 0101 | 101o |
| Hexadecimal | $41 | 41h |
| ASCII char | 'A' |
Symbols
Mathematical Operators
| Symbol | Description |
|---|---|
| + | Add |
| - | Subtract |
| * | Multiply |
| / | Divide |
| ** | Exponent |
| & | Bitwise AND |
| | | Bitwise OR |
| ^ | Bitwise XOR |
| >> | Shift right |
| << | Shift left |
| % | Modulo |
Comparison Operators
| Symbol | Description |
|---|---|
| == | Equals |
| != | Not equals |
| < | Less than |
| > | Greater than |
| <= | Less than or equals |
| >= | Greater than or equals |
Special
| Symbol | Description |
|---|---|
| -> | Accessor (functions like +) |
Labels
Named
Named label declarations must be in the follow format:
NAME:
NAME- Label name, required.
Example:
LDX #$08 loop: DEX BNE loop BRK
Output:
00000000 a2 08 ca d0 fd 00 |......|
00000006
Try it:
A label that names a subroutine can carry its calling convention — the registers it takes, returns, and destroys — in comments the tooling reads. See Documenting routines.
Temporary
Temporary/un-named labels may also be declared by placing only a colon.
:
To jump to a temporary label, the direction and count of the jumps must be given.
JMP :[+-]
[+-] - Direction, required.
N-number of +s means to jump to the temporary label that is N temporary labels
further down in the code.
N-number of -s means to jump to the temporary label that is N temporary labels
further up in the code.
Example:
LDX #$08 : DEX BNE :- BRK
Output:
00000000 a2 08 ca d0 fd 00 |......|
00000006
Try it:
Mnemonics
All 56 mnemonics are supported:
| Mnemonic | Description |
|---|---|
| ADC | Add with Carry |
| AND | Bitwise AND with Accumulator |
| ASL | Arithmetic shift left |
| BIT | Test bits |
| BCC | Branch on Carry clear |
| BCS | Branch on Carry set |
| BEQ | Branch on equal |
| BMI | Branch on minus |
| BNE | Branch on not equal |
| BPL | Branch on plus |
| BRK | Break |
| BVC | Branch on Overflow clear |
| BVS | Branch on Overflow set |
| CLC | Clear Carry |
| CLD | Clear Decimal |
| CLI | Clear Interrupt |
| CLV | Clear Overflow |
| CMP | Compare Accumulator |
| CPX | Compare X register |
| CPY | Compare Y register |
| DEC | Decrement memory |
| DEX | Decrement X register |
| DEY | Decrement Y register |
| EOR | Bitwise exclusive OR |
| INC | Increment memory |
| INX | Increment X register |
| INY | Increment Y register |
| JMP | Jump |
| JSR | Jump to subroutine |
| LDA | Load Accumulator |
| LDX | Load X register |
| LDY | Load Y register |
| LSR | Logical shift right |
| NOP | No operation |
| ORA | Bitwise OR with Accumulator |
| PHA | Push Accumulator |
| PHP | Push processor status |
| PLA | Pull Accumulator |
| PLP | Pull processor status |
| ROL | Rotate left |
| ROR | Rotate right |
| RTI | Return from Interrupt |
| RTS | Return from subroutine |
| SBC | Subtract with Carry |
| SEC | Set Carry |
| SED | Set Decimal |
| SEI | Set Interrupt |
| STA | Store Accumulator |
| STX | Store X register |
| STY | Store Y register |
| TAX | Transfer Accumulator to X register |
| TAY | Transfer Accumulator to Y register |
| TSX | Transfer Stack Pointer to X register |
| TXA | Transfer X register to Accumulator |
| TXS | Transfer X register to Stack Pointer |
| TYA | Transfer Y register to Accumulator |
Read more about 6502 opcodes here.
In addition, 24 illegal/undocumented mnemonics may be used when assembled with
the -u, --undocumented flag.
| Mnemonic | Description |
|---|---|
| AAC | AND with Accumulator |
| AAX | AND X register with Accumulator |
| ARR | AND with Accumulator |
| ASR | AND with Accumulator |
| ATX | AND with Accumulator |
| AXA | AND X register with Accumulator |
| AXS | AND X register with Accumulator |
| DCP | Subtract 1 from memory |
| DOP | No operation (x2) |
| ICS | Increase memory by 1 |
| KIL | Stop program counter |
| LAR | AND memory with stack pointer |
| LAX | Load Accumulator and X register |
| NOP | No operation |
| RLA | Rotate one bit left in memory |
| RRA | Rotate one bit right in memory |
| SBC | Subtract with Carry |
| SLO | Shift left one bit in memory |
| SRE | Shift right one bit in memory |
| SXA | AND Y register with the high byte of address |
| SYA | AND Y register with the high byte of address |
| TOP | No operation (x3) |
| XAA | Unknown |
| XAS | AND X register with Accumulator |
Read more about undocumented 6502 opcodes here.
Addressing Modes
| Mode | Example |
|---|---|
| Implied | RTS |
| Accumulator | ROL A |
| Immediate | LDA #$42 |
| Zeropage | STA <$42 |
| Zeropage, X | EOR <$42, X |
| Zeropage, Y | LDX <$42, Y |
| Absolute | STA $4200 |
| Absolute, X | EOR $4200, X |
| Absolute, Y | LDX $4200, Y |
| Indirect | JMP [$4200] |
| Indirect, X | LDA [$42, X] |
| Indirect, Y | STA [$42], Y |
| Relative | BEQ label |
nessembleuses square brackets[]instead of parentheses()in its addressing modes because the latter are used to indicate precedence in mathematical operations.
Read more about 6502 addressing modes here.
Functions
| Function | Description |
|---|---|
| HIGH() | Get high byte of address |
| LOW() | Get low byte of address |
| BANK() | Get bank of address |
Pseudo-Instructions
| Pseudo-Instruction | Description |
|---|---|
| .ascii | Convert ASCII string to bytes |
| .byte | Alias for .db |
| .checksum | Calculate crc32 checksum |
| .chr | Set CHR bank index |
| .color | Convert hex color to NES color |
| .db | Define 8-bit byte(s) |
| .defchr | Define CHR tile |
| .dephase | End a .phase block |
| .dw | Define 16-bit word(s) |
| .else | Else condition of an .if/.ifdef/.ifndef statement |
| .endenum | End .enum |
| .endif | End .if/.ifdef/.ifndef statement |
| .endm | End .macrodef |
| .enum | Start enumerated variable declarations |
| .fill | Fill with bytes |
| .font | Generate font character tile |
| .hibytes | Output only the high byte of 16-bit word(s) |
| .if | Test if condition |
| .ifdef | Test if variable is defined |
| .ifndef | Test if variable has not been defined |
| .incbin | Include binary file |
| .include | Include assembly file |
| .incpal | Include palette from PNG |
| .incpng | Include PNG |
| .incrle | Include binary data to be RLE-encoded |
| .incwav | Include WAV |
| .ines2 | Emit a NES 2.0 header |
| .ines4scr | iNES four-screen VRAM flag |
| .inesbat | iNES battery / persistent memory flag |
| .ineschr | iNES CHR count |
| .ineschrnvram | NES 2.0 battery CHR-RAM size |
| .ineschrram | NES 2.0 CHR-RAM size |
| .inesconsole | NES 2.0 console type |
| .inesexpansion | NES 2.0 default expansion device |
| .inesmap | iNES / NES 2.0 mapper number |
| .inesmir | iNES mirroring |
| .inesmiscrom | NES 2.0 miscellaneous ROM count |
| .inespc10 | iNES PlayChoice-10 flag |
| .inesprg | iNES / NES 2.0 PRG count |
| .inesprgnvram | NES 2.0 battery PRG-RAM size |
| .inesprgram | iNES / NES 2.0 PRG-RAM size |
| .inessubmap | NES 2.0 submapper number |
| .inestiming | NES 2.0 CPU/PPU timing |
| .inestrn | iNES trainer include |
| .inestv | iNES / NES 2.0 TV system |
| .inesvs | iNES VS Unisystem flag |
| .inesvshw | NES 2.0 VS System hardware type |
| .inesvsppu | NES 2.0 VS System PPU type |
| .lobytes | Output only the low byte of 16-bit word(s) |
| .macro | Call macro |
| .macrodef | Start macro definition |
| .org | Organize code |
| .out | Output debugging message |
| .phase | Assemble code for a different run-time address |
| .prg | Set PRG bank index |
| .random | Output random byte(s) |
| .rsset | Set initial value for .rs declarations |
| .rs | Reserve space for variable declaration |
| .segment | Set code segment |
| .word | Alias for .dw |
.ascii
Convert ASCII string to bytes.
Usage:
.ascii "STRING"[(+/-)NUMBER]
"STRING"- String, required. ASCII string to turn into bytes. Must be within quotes.(+/-)NUMBER- Number, optional. Amount to increase/decrease ASCII values.
Example:
.ascii "When, in disgrace with fortune and men's eyes"
Output:
00000000 57 68 65 6e 2c 20 69 6e 20 64 69 73 67 72 61 63 |When, in disgrac|
00000010 65 20 77 69 74 68 20 66 6f 72 74 75 6e 65 20 61 |e with fortune a|
00000020 6e 64 20 6d 65 6e 27 73 20 65 79 65 73 |nd men's eyes|
0000002d
Try it:
The +/- operators may also be used to increase/decrease the output.
Example:
.ascii "I all alone beweep my outcast state"-32
Output:
00000000 29 00 41 4c 4c 00 41 4c 4f 4e 45 00 42 45 57 45 |).ALL.ALONE.BEWE|
00000010 45 50 00 4d 59 00 4f 55 54 43 41 53 54 00 53 54 |EP.MY.OUTCAST.ST|
00000020 41 54 45 |ATE|
00000023
.checksum
Calculate crc32 checksum.
Usage:
.checksum LABEL
LABEL- Label, required. Label at which to start generating the checksum.
Example:
start: LDA #$01 STA <$02 .checksum start
Output:
00000000 a9 01 85 02 b8 1f ee 86 |........|
00000008
The checksum is b8 1f ee 86.
Checksums may only be performed on preceding data.
Try it:
.chr
Set CHR bank index.
Usage:
.chr NUMBER
NUMBER- Number, required. CHR bank index.
Example:
.chr 0
CHR banks are 2K bytes (0x2000) in size.
.color
Convert hex color to NES color.
Finds the closest valid NES color to the given hex color.
Usage:
.color NUMBER[, NUMBER, ...]
NUMBER- Number, required. At least one number is required., NUMBER, ...- Number(s), optional. Additional comma-separated numbers may be used.
Example:
.color $FF0000
Output:
00000000 16 |.|
00000001
Read more about the NES color palette here.
In an editor connected to the language server, hovering .color
previews the palette entries the whole argument list maps to, and hovering a
single argument previews just that color.
Try it:
.db
Define 8-bit byte(s).
Usage:
.db NUMBER[, NUMBER, ...]
NUMBER- Number, required. At least one number is required., NUMBER, ...- Number(s), optional. Additional comma-separated numbers may be used.
Example:
.db $12, $34
Output:
00000000 12 34 |.4|
00000002
Try it:
A trailing comma continues the list onto the next line, so a long run of bytes can be wrapped across several indented lines:
.db $00, $01, $02, $03, $04, $05, $06, $07
The same line-continuation rule applies to every comma-separated data directive
(.dw, .fill, .color, .hibytes, .lobytes, and .defchr).
.defchr
Define CHR tile.
Only numbers from 0-3 may be used: 0 representing black, 1 dark grey, 2
light grey, and 3 representing white.
Usage:
.defchr XXXXXXXX, XXXXXXXX, XXXXXXXX, XXXXXXXX, XXXXXXXX, XXXXXXXX, XXXXXXXX, XXXXXXXX
XXXXXXXX,- Number, required. Must be exactly 8 numbers of 8-characters each.
Example:
.defchr 333333333, 300000003, 300000003, 300000003, 300000003, 300000003, 300000003, 333333333
Output:
00000000 ff 01 01 01 01 01 01 ff ff 01 01 01 01 01 01 ff |................|
00000010
Read more about PPU pattern tables here.
Try it:
.dephase
End a .phase block. Labels defined after .dephase revert to their
physical load address. A bank or segment switch (.prg, .chr,
.segment) also ends any active phase.
Usage:
.dephase
.dw
Define 16-bit word(s).
Usage:
.dw NUMBER[, NUMBER, ...]
NUMBER- Number, required. At least one number is required., NUMBER, ...- Number(s), optional. Additional comma-separated numbers may be used.
Example:
.dw $1234, $45678
Output:
00000000 34 12 78 56 |4.xV|
00000004
Try it:
.else
Else condition of an .if/.ifdef/.ifndef statement.
Usage:
.else
Example:
.ifdef SOMETHING STA $00 .else STA $01 .endif
.endenum
End .enum.
Usage:
.endenum
Example:
.enum $0080 TEST_0 .rs 1 TEST_1 .rs 2 TEST_2 .rs 1 .endenum
.endif
End .if/.ifdef/.ifndef statement.
Usage:
.endif
Example:
.ifdef SOMETHING STA $00 .else STA $01 .endif
.endm
End .macrodef.
Usage:
.endm
Example:
.macrodef TEST_MACRO LDA #\1 STA <\2 .endm
See the section on Macros for more information.
.enum
Start enumerated variable declarations.
Usage:
.enum START[, INC]
START- Number, required. Value at which to start enumerating., INC- Number, optional. Amount to increment after each enumeration.
Example:
.enum $0080 TEST_0 .rs 1 TEST_1 .rs 2 TEST_2 .rs 1 .endenum
.fill
Fill with bytes.
Usage:
.fill COUNT[, VALUE]
COUNT- Number, required. Number of bytes to fill., VALUE- Number, optional. Value of each byte. Defaults to $FF.
Example:
.fill 16
Output:
00000000 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff |................|
00000010
Try it:
.font
Generate font character tile.
Usage:
.font START[, END]
START- Character/number, required. Starting ASCII character or code.[, END]- Character/number, optional. Ending ASCII character or code. If included, all font tiles fromSTARTto[, END](inclusive) will be generated.
Example:
.font 'A', 'G'
Output:
00000000 38 44 7c 44 44 44 44 00 38 44 7c 44 44 44 44 00 |8D|DDDD.8D|DDDD.|
00000010 78 44 78 44 44 44 78 00 78 44 78 44 44 44 78 00 |xDxDDDx.xDxDDDx.|
00000020 38 44 40 40 40 44 38 00 38 44 40 40 40 44 38 00 |8D@@@D8.8D@@@D8.|
00000030 78 44 44 44 44 44 78 00 78 44 44 44 44 44 78 00 |xDDDDDx.xDDDDDx.|
00000040 7c 40 70 40 40 40 7c 00 7c 40 70 40 40 40 7c 00 ||@p@@@|.|@p@@@|.|
00000050 7c 40 70 40 40 40 40 00 7c 40 70 40 40 40 40 00 ||@p@@@@.|@p@@@@.|
00000060 3c 40 4c 44 44 44 38 00 3c 40 4c 44 44 44 38 00 |<@LDDD8.<@LDDD8.|
00000070
Read more about PPU pattern tables here.
Try it:
.hibytes
Output only the high byte of 16-bit word(s).
Usage:
.hibytes NUMBER[, NUMBER]
NUMBER- Number, required. At least one number is required., NUMBER, ...- Number(s), optional. Additional comma-separated numbers may be used.
Example:
.hibytes $1234, $5678
Output:
00000000 12 56 |.V|
00000002
Try it:
.if
Test if condition.
Can be accompanied by an .else and must be accompanied by an
.endif.
Usage:
.if CONDITION
CONDITION- Condition, required. The code that follows will be processed if the condition is true. See Comparison Operators.
Example:
.if SOMETHING == $01 LDA #$01 .endif
.ifdef
Test if variable is defined.
Can be accompanied by an .else and must be accompanied by an
.endif.
Usage:
.ifdef VARIABLE
VARIABLE- Variable/constant/etc., required. The code that follows will be processed if the variable has been defined.
Example:
.ifdef SOMETHING STA $00 .else STA $01 .endif
.ifndef
Test if variable has not been defined.
Can be accompanied by an .else and must be accompanied by an
.endif.
Usage:
.ifndef VARIABLE
VARIABLE- Variable/constant/etc., required. The code that follows will be processed if the variable has not been defined.
Example:
.ifndef SOMETHING STA $01 .else STA $00 .endif
.incbin
Include binary file.
Usage:
.incbin "FILENAME"[, OFFSET[, LIMIT]]
"FILENAME"- Path to file, required. Must be within quotes.[, OFFSET- File offset index, optional. Index at which to start including binary file.[, LIMIT]]- Limit in bytes, optional. Number of total bytes to include.
Example:
.incbin "file.bin"
.include
Include assembly file.
Usage:
.incbin "FILENAME"
"FILENAME"- Path to file, required. Must be within quotes.
Example:
.include "file.asm"
Included files share a global state with other included files and the main entry point file. That means if a variable is defined in one file, it is available to all other files, provided that they are included after the definition.
Relative filenames in
.include— and in every filename-based directive (.incbin,.incpng,.incpal,.incrle,.incwav,.inestrn) — are resolved relative to the directory of the file that contains the directive. A file included from a subdirectory therefore resolves its own includes and assets from that subdirectory, not from the top-level project directory. A path prefixed@/is the escape from this: see Project-root-relative paths below.
Declaring a filename argument
Any filename argument may be written with a file:// prefix, which declares
that the string names an input file:
.incbin "file://logo.chr" .include "file://defs.asm"
The prefix is stripped before the path is used, so the two spellings load exactly
the same file — file:// adds information about the argument rather than
changing it. It is a marker, not a URL scheme: there is no host part and no
percent-decoding, and file:///path/to/logo.chr is simply the absolute spelling.
The declaration earns its keep on a custom pseudo-instruction, where the assembler otherwise cannot tell that a string is a path. On the built-in directives above it is accepted, harmless, and redundant — their arguments are already known to be filenames — but it lets you write every path in a project the same way. Editors make a declared path clickable either way.
Project-root-relative paths
A filename argument beginning @/ resolves from the project root instead
of the containing file's directory:
.include "@/lib/macros.asm" .incbin "@/assets/logo.chr" .incpng "@/art/tiles.png"
@/lib/macros.asm names the same file no matter which directory the .include
sits in — copying that line into a file three levels deeper still finds it,
where a relative path would need ../../../lib/macros.asm at that depth and
break the moment the file moves again. Everything after the @/ is a path
relative to the root:
| Argument | Resolves to |
|---|---|
"@/assets/logo.chr" | <root>/assets/logo.chr |
"@weird/logo.chr" | <containing dir>/@weird/logo.chr (unchanged — @ not followed by / is an ordinary path character) |
"./@x/logo.chr" | <containing dir>/@x/logo.chr (the escape hatch for a directory literally named @) |
The project root is found by walking up from the entry file for the
nearest .nessemblerc, .nessemblerc.json, or .nessembleignore — the root
is the directory containing that marker. A project that already has one of
these files gets @/ for free. With no marker anywhere above it, the entry
file's own directory is the root, which makes @/foo mean ./foo for a lone
source file. The --root CLI flag, and an open editor's
workspace folder, both override the walk-up explicitly.
@/ composes with file://: the declaration is stripped first, so
"file://@/lib/defs.asm" is a declared, root-relative path — see
Declaring a filename argument above and
Declaring file arguments for what a
custom pseudo-op's script receives.
A @/ path that cannot be resolved is a hard error rather than a silent
fallback to file-relative resolution: this happens when no project root can be
determined at all (only reachable from the browser playground, which has no
filesystem), or when the path climbs back out of the root, e.g.
"@/../secret.bin".
.incpal
Include palette from PNG.
Usage:
.incpal "FILENAME"
"FILENAME"- Path to file, required. Must be within quotes.
Example:
.incpal "palette.png"
The PNG will be scanned, row-by-row/pixel-by-pixel, from the top-left to the bottom-right until it encounters 4 different, but not necessarily unique, colors.
.incpng
Include PNG.
Converts the PNG to CHR tiles. The image must include only 4 colors:
| Color | Name | RGB | Hex |
|---|---|---|---|
| Black | 0, 0, 0 | #000000 | |
| Dark Grey | 85, 85, 85 | #555555 | |
| Light Grey | 170, 170, 170 | #AAAAAA | |
| White | 255, 255, 255 | #FFFFFF |
Other colors may be used, but accuracy is not guaranteed.
Usage:
.incpng "FILENAME"
"FILENAME"- Path to file, required. Must be within quotes.
Example:
.incpng "image.png"
Read more about PPU pattern tables here.
.incrle
Include binary data to be RLE-encoded
The RLE-encoding scheme used is one featured in a few Konami NES titles, known
as Konami RLE. The breakdown of bytes:
| Value | Description |
|---|---|
| 00-80 | Read another byte and write it to the output N times |
| 81-FE | Copy N-128 bytes from input to output |
| FF | End of compressed data |
Usage:
.incrle "FILENAME"
"FILENAME"- Path to file, required. Must be within quotes.
Read more about NES RLE compression here.
.incwav
Include WAV.
Converts WAV to a 1-bit PCM.
Usage:
.incwav "FILENAME"[, AMPLITUDE]
"FILENAME"- Path to file, required. Must be within quotes.[, AMPLITUDE]- Amplitude, optional. Amplitude of WAV.
Example:
.incwav "audio.wav", 24
.ines2
Emit a NES 2.0 header.
Sets bits 2-3 of Flags 7 to the NES 2.0 identifier (10) and switches the
output header from iNES 1.0 to NES 2.0,
which widens the mapper (0-4095) and PRG/CHR sizes and repurposes bytes 8-15.
When NES 2.0 mode is active, some existing directives change meaning:
- .inesprg / .ineschr become 12-bit (the byte-9 MSB nibbles are written automatically for counts above 255).
- .inesmap becomes 12-bit (writes byte 8 as well).
- .inesprgram targets the NES 2.0 PRG-RAM field and takes a byte size (not 8 KB units).
- .inestv provides the NTSC/PAL fallback for the .inestiming byte.
- .inesvs / .inespc10 become sugar for the .inesconsole type.
NES 2.0-only directives (.inessubmap, .inesprgnvram, .ineschrram, .ineschrnvram, .inestiming, .inesconsole, .inesvsppu, .inesvshw, .inesmiscrom, .inesexpansion) require this directive.
Usage:
.ines2 FLAG
FLAG- Number, required. Non-zero to emit a NES 2.0 header.
Example:
.ines2 1
.ines4scr
iNES four-screen VRAM flag.
Sets bit 3 of Flags 6 (the "alternative nametable layout" bit), used by boards that provide four-screen VRAM instead of the hard-wired mirroring selected by .inesmir.
Usage:
.ines4scr FLAG
FLAG- Number, required. Non-zero to set four-screen VRAM.
Example:
.ines4scr 1
.inesbat
iNES battery / persistent memory flag.
Sets bit 1 of Flags 6, indicating the cartridge contains battery-backed PRG-RAM
at $6000-$7FFF (or other persistent memory).
Usage:
.inesbat FLAG
FLAG- Number, required. Non-zero to indicate persistent memory.
Example:
.inesbat 1
.ineschr
iNES CHR count.
Usage:
.ineschr COUNT
COUNT- Number, required. Number of CHR banks. In NES 2.0 mode a count above 255 also writes the byte-9 MSB nibble (up to 4095).
Example:
.ineschr 1
.ineschrnvram
NES 2.0 battery CHR-RAM size.
Sets the battery-backed CHR-RAM field (byte 11 bits 4-7) of a NES 2.0 header. Requires .ines2.
Usage:
.ineschrnvram BYTES
BYTES- Number, required. Size in bytes:0, or a power-of-two byte count from 128 to 2097152 (stored as the shift countsize = 64 << n).
Example:
.ineschrnvram 8192
.ineschrram
NES 2.0 CHR-RAM size.
Sets the volatile CHR-RAM field (byte 11 bits 0-3) of a NES 2.0 header. Requires .ines2.
Usage:
.ineschrram BYTES
BYTES- Number, required. Size in bytes:0, or a power-of-two byte count from 128 to 2097152 (stored as the shift countsize = 64 << n).
Example:
.ineschrram 8192
.inesconsole
NES 2.0 console type.
Sets bits 0-1 of Flags 7. Requires .ines2. This is the canonical form of .inesvs (value 1) and .inespc10 (value 2); setting a conflicting combination is an error.
| Value | Console type |
|---|---|
| 0 | Nintendo NES / FC |
| 1 | VS System |
| 2 | PlayChoice-10 |
| 3 | Extended (unsupported) |
Value 3 (extended console type) is not yet supported.
Usage:
.inesconsole NUMBER
NUMBER- Number, required. Console type (0-3).
Example:
.inesconsole 1
.inesexpansion
NES 2.0 default expansion device.
Sets byte 15 (bits 0-5) of a NES 2.0 header. Requires .ines2.
Usage:
.inesexpansion NUMBER
NUMBER- Number, required. Expansion device (0-63).
Example:
.inesexpansion 1
.inesmap
iNES mapper number.
Usage:
.inesmap NUMBER
NUMBER- Number, required. Mapper number. iNES supports 0-255; in NES 2.0 mode the range widens to 0-4095 (byte 8 holds the high nibble). A value above 255 requires .ines2.
Read more about NES mappers here.
.inesmir
iNES mirroring.
Sets bit 0 of Flags 6, the hard-wired nametable arrangement. The other Flags 6 bits are controlled by their own directives: .inesbat (battery), .inestrn (trainer), and .ines4scr (four-screen VRAM).
xxxxxxx0
|
+- Mirroring: 0: horizontal (vertical arrangement)
1: vertical (horizontal arrangement)
| Value | Mirroring |
|---|---|
| 0 | Horizontal |
| 1 | Vertical |
Usage:
.inesmir NUMBER
NUMBER- Number, required. Mirroring type.
.inesmiscrom
NES 2.0 miscellaneous ROM count.
Sets byte 14 (bits 0-1) of a NES 2.0 header, the number of miscellaneous ROMs present. Requires .ines2.
Usage:
.inesmiscrom NUMBER
NUMBER- Number, required. Number of miscellaneous ROMs (0-3).
Example:
.inesmiscrom 1
.inespc10
iNES PlayChoice-10 flag.
Sets bit 1 of Flags 7, marking the ROM as a PlayChoice-10 title. This bit is not part of the official specification and most emulators ignore it. In NES 2.0 mode this is sugar for .inesconsole type 2.
Only the header bit is set. The optional 8 KB PlayChoice INST-ROM and PROM data sections are not emitted.
Usage:
.inespc10 FLAG
FLAG- Number, required. Non-zero to mark a PlayChoice-10 title.
Example:
.inespc10 1
.inesprg
iNES PRG count.
Usage:
.inesprg COUNT
COUNT- Number, required. Number of PRG banks. In NES 2.0 mode a count above 255 also writes the byte-9 MSB nibble (up to 4095).
Example:
.inesprg 1
.inesprgnvram
NES 2.0 battery PRG-RAM size.
Sets the battery-backed PRG-RAM field (byte 10 bits 4-7) of a NES 2.0 header. Requires .ines2.
Usage:
.inesprgnvram BYTES
BYTES- Number, required. Size in bytes:0, or a power-of-two byte count from 128 to 2097152 (stored as the shift countsize = 64 << n).
Example:
.inesprgnvram 8192
.inesprgram
iNES / NES 2.0 PRG-RAM size.
In iNES mode, sets byte 8 of the header, the size of PRG-RAM in 8 KB units (a
value of 0 infers 8 KB for compatibility).
In NES 2.0 mode, sets the volatile PRG-RAM field (byte 10 bits 0-3) and the argument is a byte size instead — pair it with .inesprgnvram for battery-backed PRG-RAM.
Usage:
.inesprgram SIZE
SIZE- Number, required. In iNES mode, PRG-RAM size in 8 KB units. In NES 2.0 mode, a byte size:0, or a power-of-two byte count from 128 to 2097152 (stored as the shift countsize = 64 << n).
Example:
.inesprgram 1
.inessubmap
NES 2.0 submapper number.
Sets byte 8 (bits 4-7) of a NES 2.0 header, the submapper that distinguishes variants of a mapper. Requires .ines2.
Usage:
.inessubmap NUMBER
NUMBER- Number, required. Submapper number (0-15).
Example:
.inessubmap 1
.inestiming
NES 2.0 CPU/PPU timing.
Sets byte 12 of a NES 2.0 header, the region timing. Requires .ines2. When unset, .inestv provides the NTSC/PAL value.
| Value | Timing |
|---|---|
| 0 | RP2C02 (NTSC) |
| 1 | RP2C07 (PAL) |
| 2 | Multi-region |
| 3 | UMC 6527P (Dendy) |
Usage:
.inestiming NUMBER
NUMBER- Number, required. Timing (0-3).
Example:
.inestiming 0
.inestrn
iNES trainer include.
The assembled trainer must be no larger than 512 (0x200) bytes. The appropriate flag is automatically set in the iNES header to indicate a trainer is present.
Usage:
.inestrn "FILENAME"
"FILENAME"- Path to file, required. Must be within quotes.
Example:
.inestrn "trainer.asm"
.inestv
iNES TV system.
Sets bit 0 of Flags 9, the TV system the ROM targets. PAL is also mirrored into
the unofficial Flags 10 TV-system field (bits 0-1: 0 NTSC, 2 PAL) that some
emulators honor. In NES 2.0 mode this instead provides the NTSC/PAL
fallback for the .inestiming byte.
xxxxxxx0
|
+- TV system: 0: NTSC
1: PAL
| Value | TV system | Flags 9 | Flags 10 |
|---|---|---|---|
| 0 | NTSC | 0 | 0 |
| 1 | PAL | 1 | 2 |
Usage:
.inestv SYSTEM
SYSTEM- Number, required.0for NTSC,1for PAL.
Example:
.inestv 1
.inesvs
iNES VS Unisystem flag.
Sets bit 0 of Flags 7, marking the ROM as a VS Unisystem arcade title. In NES 2.0 mode this is sugar for .inesconsole type 1.
Usage:
.inesvs FLAG
FLAG- Number, required. Non-zero to mark a VS Unisystem title.
Example:
.inesvs 1
.inesvshw
NES 2.0 VS System hardware type.
Sets byte 13 (bits 4-7) of a NES 2.0 header. Only meaningful when the .inesconsole type is VS (1); ignored otherwise. Requires .ines2.
Usage:
.inesvshw NUMBER
NUMBER- Number, required. VS System hardware type (0-15).
Example:
.inesvshw 0
.inesvsppu
NES 2.0 VS System PPU type.
Sets byte 13 (bits 0-3) of a NES 2.0 header. Only meaningful when the .inesconsole type is VS (1); ignored otherwise. Requires .ines2.
Usage:
.inesvsppu NUMBER
NUMBER- Number, required. VS System PPU type (0-15).
Example:
.inesvsppu 0
.lobytes
Output only the low byte of 16-bit word(s).
Usage:
.lobytes NUMBER[, NUMBER]
NUMBER- Number, required. At least one number is required., NUMBER, ...- Number(s), optional. Additional comma-separated numbers may be used.
Example:
.lobytes $1234, $5678
Output:
00000000 34 78 |4x|
00000002
Try it:
.macro
Call macro.
Usage:
.macro MACRO[, NUMBER, ...]
MACRO- Name, required. Name of previously-defined macro., NUMBER, ...- Number(s), optional. Additional comma-separated numbers may be used.
Example:
.macro TEST_MACRO
See the section on Macros for more information.
.macrodef
Start macro definition.
Usage:
.macrodef MACRO CODE... .endm
MACRO- Name, required. Name of macro.CODE...- Code, required. Assembly code.
Example:
.macrodef TEST_MACRO LDA #\1 STA <\2 .endm
See the section on Macros for more information.
.org
Organize code.
Set the address of the current bank in which to start organizing code.
Usage:
.org ADDRESS
Example:
.org $C000
.phase
Assemble code for a different run-time address.
When a PRG bank is swapped in at a different address than the one it is laid out
at, the address labels receive (its .org) does not match where the code
actually runs. .phase ADDRESS overrides the address labels receive so it
reflects the run-time (post-swap) location, while ROM layout keeps flowing from
.org unchanged. This removes the need to subtract the swap offset from every
label by hand.
The override stays in effect until .dephase or a bank/segment
switch (.prg, .chr, .segment). It applies only
to symbol values; branch targets and emitted bytes are unaffected, since the
offset cancels in any address difference.
Usage:
.phase ADDRESS
ADDRESS- Number, required. The run-time address the current location maps to.
Example:
.prg 1 .org $C000 ; laid out at $C000 in ROM .phase $8000 ; but swapped in at $8000 at run time label_def: ; label_def == $8000 NOP JMP label_def .dephase
.prg
Set PRG bank index.
Usage:
.prg NUMBER
NUMBER- Number, required. PRG bank index.
Example:
.prg 0
PRG banks are 4K bytes (0x4000) in size.
.random
Output random byte(s).
The algorithm for the PRNG is the suggested POSIX implementation of rand().
Usage:
.random [SEED[, COUNT]]
[SEED- Number or string, optional. Seeds the random number generator. Defaults to the current system time.[, COUNT]]- Number of bytes to output, optional. Defaults to 1.
Example:
.random "Secret Key", 16
.rsset
Set initial value for .rs declarations.
Usage:
.rsset ADDRESS
ADDRESS- Number, required. Address to start.rsdeclarations.
Example:
.rsset $0000
.rs
Reserve space for variable declaration.
Usage:
VARIABLE .rs NUMBER
VARIABLE- Variable name, required. Name of variable to declare.NUMBER- Number (in bytes) to reserve, required.
Example:
.rsset $0000 label_01 .rs 1 label_02 .rs 2 label_03 .rs 1 .db label_01, label_02, label_03
Output:
00000000 00 01 03 |...|
00000003
Try it:
label_01 .rs 1 label_02 .rs 2 label_03 .rs 1
.db label_01, label_02, label_03
.segment
Set code segment.
Usage:
.segment "SEGMENT[0-9]+"
SEGMENT- Type of segment, required.PRGorCHR.[0-9]+- Number, required. Segment index.
The whole segment must be within quotes.
Example:
.segment "PRG1"
This is an alias for
.prg x.
Optional Scripts
Some scripts are included with nessemble, but totally optional. They must be
installed with the scripts command which provides additional
pseudo-instructions to use.
| Pseudo-Instruction | Description |
|---|---|
| .ease | Generates bytes to simulate easing |
.ease
Generates bytes to simulate easing
Usage:
.ease FUNCTION[, START[, END[, STEPS]]]
FUNCTION- String, required. Easing function to perform. Must be within quotes.[, START- Number, optional. Starting value. Defaults to 0.[, END- Number, optional. Ending value. Defaults to 16.[, STEPS]]]- Number, optional. Steps to perform. Defaults to 16.
Valid FUNCTIONs include:
- "easeInQuad"
- "easeOutQuad"
- "easeInOutQuad"
- "easeInCubic"
- "easeOutCubic"
- "easeInOutCubic"
- "easeInQuint"
- "easeOutQuint"
- "easeInOutQuint"
- "easeInBounce"
- "easeOutBounce"
- "easeInOutBounce"
Example:
.ease "easeOutBounce", 0, $20, $40
Output:
00000000 00 00 00 00 00 01 02 02 03 04 06 07 08 0a 0b 0d |................|
00000010 0f 11 13 16 18 1a 1d 1f 1e 1d 1c 1b 1a 19 19 18 |................|
00000020 18 18 18 18 18 18 18 19 19 1a 1b 1c 1d 1e 1f 1f |................|
00000030 1e 1e 1e 1e 1e 1e 1e 1e 1f 1f 1f 1f 1f 1f 1f 20 |............... |
00000040
Try it — the .ease script runs in your browser (custom pseudo-op scripting,
compiled to WebAssembly):
Macros
Macros may be utilized to maximize code-reuse and may also be treated as custom functions.
Example:
.macrodef TEST_MACRO LDA #$00 STA $2005 STA $2005 .endm .macro TEST_MACRO
Output:
00000000 a9 00 8d 05 20 8d 05 20 |.... .. |
00000008
Try it:
.macro TEST_MACRO
Parameters
Macros may also have parameters.
Example:
.macrodef TEST_MACRO LDA #\1 STA \2 STA \2 .endm .macro TEST_MACRO, $00, $2005
Output:
.macrodef TEST_MACRO LDA #\1 STA \2 STA \2 .endm .macro TEST_MACRO, $00, $2005
One macro may have up to 256 parameters which are denoted with a \ prefix. The
first parameter being \1, the next \2, and so on up to \256. All
parameters must be numbers (or label variables).
There is also a pseudo-parameter, \#, that returns the number of input
parameters.
Example:
.macrodef COUNT_PARAMS .db \# .endm .macro COUNT_PARAMS, $01, $01, $01
Output:
00000000 03 |.|
00000001
There is another pseudo-parameter, \@, that returns a unique number every time
the macro is called.
Example:
.macrodef TEST_MACRO LDX #$08 label_\@: DEX BNE label_\@: .endm .macro TEST_MACRO .macro TEST_MACRO .macro TEST_MACRO
Output:
00000000 a2 08 ca d0 fd a2 08 ca d0 fd a2 08 ca d0 fd |...............|
0000000f
A \@-uniquified label (such as label_\@ above) is a real label — it lands in
the symbol table with a distinct name per invocation. To keep list files (-l)
readable, these macro-created labels are omitted from the list by default;
pass --mlist to include them.
Editor support
nessemble ships a built-in Language Server for its flavor of 6502
assembly. It runs from the CLI and speaks the Language Server Protocol over
stdio, so any LSP-capable editor — VS Code, Cursor, Neovim, Helix, Emacs
(eglot/lsp-mode), Sublime Text (LSP), and others — can drive it.
Starting the server
nessemble lsp
The server reads LSP messages on stdin and writes them to stdout, the
transport every LSP client expects. You normally don't run this by hand; you
point your editor's LSP client at it and the editor manages the process.
Features
Once connected, the server provides:
- Diagnostics — errors and warnings as you type, each underlined at the offending token. Several problems are reported at once (the analyzer recovers past the first error), and includes are followed.
- Lint hints — the same style findings as the
nessemble lintCLI appear inline, at a gentler severity (Information/Hint) and tagged with thenessemble-lintsource so they read as suggestions distinct from assembler errors. They honor the project's.nessemblerclintconfig (rule severities, comment window, ignored label names), and clear as soon as you document the flagged block. The same pass checks routine signatures against the code — a routine that writes a register its@nessemble-clobbersomits is reported where the comment and the code disagree — and flags a mistyped or misplaced comment directive — a directive that would otherwise fail silently. - Project-aware analysis — when a workspace folder is open, a file that is
.included into a larger program is analyzed in the context of that program, so symbols defined in sibling or parent files are not reported as undefined. The server discovers entry points from the workspace's.includegraph (no configuration needed) and reflects unsaved edits across files. The open workspace folder also doubles as the project root for@/paths, overriding the usual.nessemblercwalk-up the same way the CLI's--rootdoes — so a file opened directly, with no workspace folder, falls back to that walk-up instead. - Completion — instruction mnemonics, assembler directives, and the
labels, constants, and macros defined in the current buffer. Typing
.triggers directive completion. Inside a comment, the comment directives are offered instead of code — including@nessemble-coverage-ignorepre-filled withstartand withend— each with its documentation. A comment directly above an undocumented label also offers a routine signature block, which scaffolds@nessemble-param/-returns/-clobbersin one insertion. Inside a filename argument — the path of.include,.incbin,.incpng,.incpal,.incrle,.incwav,.inestrn, or any argument written with afile://prefix — filenames from that directory are offered instead, filtered to what the directive can use (.incpngoffers PNGs,.includeoffers assembly sources, a custom pseudo-instruction offers everything, since its script may read any format). Directories are always offered, and typing/walks into one.@/is offered at the start of an empty argument, and typing it in switches completion to list the project root instead of the current directory. - Formatting — “format document” applies the opinionated house style
(indentation, comma spacing, data-block consolidation, routine spacing) while
preserving comments. It runs the same engine as the
nessemble formatCLI command, so editors and the command line produce identical output. Formatting is idempotent and never changes the assembled ROM. - Semantic highlighting — tokens are classified (mnemonic, directive,
number, string, comment, identifier, operator) for richer coloring than a
regex grammar can offer. A comment carrying a
comment directive additionally gets the
documentationmodifier, so themes can set it apart from prose. - Outline & navigation — a document outline of labels, constants, and
macros, with a documented routine's clobber list shown in its detail, so a
file's register discipline reads at a glance; go-to-definition (cmd/ctrl-click) and find-all-references for symbols.
With a workspace folder open, go-to-definition follows
.includes across the project, so it reaches a symbol defined in a sibling or parent file. - Clickable file paths — every filename argument is a link, so
cmd/ctrl-clicking the path in
.include "defs.asm"or.incpng "hero.png"opens that file. A custom pseudo-instruction's argument becomes clickable when it is declared with afile://prefix, which is how the editor knows the string is a path at all. Paths resolve the way the assembler resolves them — relative to the file that contains the directive, or from the project root for a@/path — and a path that doesn't resolve is deliberately not linked: it is reported as an error instead. - Hover — opcode and addressing-mode details for an instruction, the
description of a directive or comment directive,
and the resolved value of a constant or label.
A constant or label is also documented with the run of comment lines
immediately preceding its definition, so an explanatory comment written above
a symbol appears when you hover over any use of it.
A routine carrying signature annotations
additionally shows its calling convention as a table — what it takes, what it
returns, and what it clobbers — at every use, including the operand of a
JSR, and including calls into another open file. That is the whole point: "does this call eat myY?" is answered without leaving the call site. Hovering.colorpreviews the palette it produces: the whole argument list is shown as the row of NES colors it maps to, with each argument's RGB, the palette index the assembler emits for it, and the color the PPU actually shows; hovering a single argument previews just that one color. Arguments are expressions, so constants and arithmetic are resolved first, and an argument the buffer can't resolve is listed as unresolved rather than guessed at. The swatches are drawn as an image, which graphical editors render inline; a terminal editor shows the same values as text. Hovering a filename argument shows the absolute path it resolved to and what is there — the file's size, a PNG's pixel dimensions, or not found — which answers "is it picking up the file I think it is?" without assembling. For a@/path this doubles as "what root did it pick?": a.nessemblercadded anywhere above the file changes the answer, and this is where that becomes visible without a build. - Folding — macro (
.macrodef….endm) and conditional (.if*….endif) blocks, and runs of consecutive comments, can be collapsed. - Rename — renaming a symbol updates its definition and every use across the open buffers.
- Code actions — convert a numeric literal between hexadecimal, decimal, and
binary, rename a deprecated comment directive (
@fmt) to its canonical spelling (@nessemble-format), scaffold a signature block over an undocumented routine, and — when the linter catches a routine writing a register its@nessemble-clobbersomits — add that register to the list, keeping the list in canonical order and any trailing prose intact. - Inlay hints — a
JSRwhose target declares a clobber list shows that list at the end of the call line (JSR draw_sprite ‹A, X, Y›), so the cost of a call is visible without hovering. Editors toggle inlay hints on and off with their own setting. - Custom pseudo-instructions — directives declared in a
--pseudo-style mapping file in the workspace are recognized, so they aren't flagged as unknown; cmd/ctrl-click on one opens the script that implements it, and hovering it shows the script's path and the doc comment above itscustomfunction, if any.
Pseudo-op scripts (.rhai)
The server also understands the Rhai scripts a --pseudo
mapping's directives run — the host API those scripts call is documented once,
in the Extending page's reference table, and
served here as completion and hover so a script author doesn't have to keep
that page open in another tab. Opening a .rhai file gets:
- Completion, hover, and signature help for every function, method, and
property a script can call — signature, one-line summary, an availability
note when a build doesn't have it (a script running in the browser assembler
has no filesystem or random-number functions), and a link to the docs
section that explains it. This half needs no scripting host at all, so it
works even in a build made with
--no-default-features --features lsp. - Syntax diagnostics from the same compiler the assembler runs the script
through, plus four lints that catch mistakes Rhai's own dynamic dispatch
would otherwise defer to a build that reaches the directive: a script a
pseudo.txtmaps that defines nocustom(ints, texts)function;customdeclared with other than two parameters; a statement written outside everyfn(it never runs —customis called without evaluating the script body first, so a top-levelconstisVariable not foundthe moment a.ifbranch that used to skip the call stops skipping it); and a call that resolves to no script-local function and no host function, when it is a near-miss of one that is (decode_png_filis flagged; an unrelated Rhai built-in this catalog doesn't list is not). - An outline of the script's functions (
customfirst), and folding of each function's body and of comment runs. - Go-to-definition and find-all-references for a script-local function.
Diagnostics, the lints, the outline, folding, and script-local navigation need
the scripting feature (on by default; see Notes).
Editors other than VS Code need .rhai routed to the server the same way
.asm/.s is — add the rhai language id alongside nessemble in the
client's document selector. A Rhai syntax-highlighting extension, if you have
one, keeps working: coloring and this server's diagnostics/completion are
independent providers for the same language id.
Editor setup
The server needs no configuration beyond the command nessemble lsp and a file
type. Associate the .asm extension (or a dedicated language id such as
nessemble) with the server in your editor's LSP settings.
Neovim (nvim-lspconfig)
vim.api.nvim_create_autocmd('FileType', {
pattern = 'asm',
callback = function(args)
vim.lsp.start({
name = 'nessemble',
cmd = { 'nessemble', 'lsp' },
root_dir = vim.fs.dirname(args.file),
})
end,
})
Helix (languages.toml)
[language-server.nessemble]
command = "nessemble"
args = ["lsp"]
[[language]]
name = "assembly"
language-servers = ["nessemble"]
VS Code / Cursor
VS Code can't spawn a stdio language server on its own — it needs a client
extension. nessemble ships one, built and attached to every release as
nessemble_<v>.vsix. (Cursor is a VS Code fork and uses the same extension
model, so the same .vsix installs there.)
The extension is a thin client: it registers a nessemble language for .asm
and .s files, a rhai language for .rhai files, and runs nessemble lsp.
Every feature listed above comes from the server, so the editor can't drift
from the assembler or the CLI. It carries no copy of nessemble — one
universal .vsix serves every platform, and the executable it drives is the
one you installed.
-
Make sure
nessembleis on yourPATH(nessemble --versionshould print2.5.0or newer). If it lives somewhere offPATH, point thenessemble.serverPathsetting at it. -
Download
nessemble_<v>.vsixfrom the releases page. -
Install it — either from the Extensions view's Install from VSIX… command (the
…menu in its title bar), or from a terminal:code --install-extension nessemble_<v>.vsixIn Cursor, the command is
cursor --install-extension. -
Open a
.asmfile. Diagnostics, lint hints, completion, hover, formatting, semantic highlighting, outline, go-to-definition, and rename all work immediately; the server starts on the firstnessemblefile you open. Opening a.rhaiscript a--pseudomapping refers to gets the pseudo-op script features the same way.
If the executable can't be found, the extension says so and offers to open the installation docs or the setting — it does not fail silently.
Extension settings
| Setting | Default | What it does |
|---|---|---|
nessemble.serverPath | nessemble | Path to the nessemble executable. Looked up on PATH when left as the bare name. |
nessemble.serverArgs | ["lsp"] | Arguments used to start the server. |
nessemble.trace.server | off | Log LSP traffic to the nessemble output channel (messages or verbose). Useful when reporting a bug. |
Changing the path or arguments restarts the server in place — no window reload.
Coloring
The extension deliberately ships no TextMate grammar. Coloring comes from
the server's semantic tokens, produced by the assembler's own lexer, so it can
never disagree with how a file actually assembles — the same reasoning that
governs the in-browser assembler
and the code blocks in these docs. One consequence: a .asm file is uncolored
for the moment before the server connects, and stays uncolored if nessemble
isn't installed.
Building the extension from source
The extension lives in editors/vscode/.
With npm on your PATH:
cargo run -p xtask -- vsix
That packages nessemble_<workspace version>.vsix in the repository root —
the exact artifact the release pipeline publishes. To iterate on the extension
instead, open editors/vscode/ in VS Code, run npm install, and press
F5 to launch an Extension Development Host with it loaded.
Format on save
The server advertises document formatting, so once the extension is connected you
can have VS Code / Cursor reformat on every save. Add this to your settings.json
(User or Workspace):
{
"[nessemble]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "kevinselwyn.nessemble"
}
}
- The
[nessemble]scope targets the language id the extension registers. If you instead associated.asmwith VS Code's built-inasmlanguage, use"[asm]". editor.formatOnSaveperforms the on-save formatting.editor.defaultFormatternames the provider to use — the extension identifier,<publisher>.<name>. Setting it avoids the "multiple formatters" prompt when another extension also claims.asmfiles.
Because the server shares its engine with the CLI, saving a file produces exactly
the same result as running nessemble format --write on it.
Any other client that can spawn a stdio language server for .asm/.s files
works the same way.
Emacs (eglot)
(add-to-list 'eglot-server-programs
'(asm-mode . ("nessemble" "lsp")))
Notes
- The server was compiled in by default. A build made with
--no-default-features(without thelspfeature) still acceptsnessemble lsp, but the command reports that language-server support was not included. - The server analyzes the in-editor buffer, so diagnostics reflect unsaved changes.
- Project-aware analysis needs a workspace folder to be open (most editors send one automatically). Opening a lone file with no folder still works, but each file is then analyzed on its own, so cross-file symbols may be reported as undefined.
- Custom pseudo-instructions are discovered from any
*.txtmapping file in the workspace (or next to the open file) whose.name = scriptentries point at existing scripts — the same mapping you pass to the CLI's--pseudo. Their scripts are not executed during analysis, so the bytes they emit aren't modeled; addresses after a custom pseudo-op may be approximate. - A
.rhaiscript's completion, hover, and signature help are compiled in unconditionally — even a build with no scripting host at all serves them. Its diagnostics, lints, outline, folding, and script-local definition/references need thescriptingfeature, which is also on by default;--no-default-features --features lspdrops them the same way it dropslsp's own features from a build with noscripting.
Extending
nessemble can be extended with custom pseudo-instructions written in
Rhai, a small, pure-Rust scripting language. Scripts can also
read and write files (see Filesystem access), so run only
scripts you trust.
Host API reference
Every function, method, property, and type a script can call, grouped by what it's for. Each signature links to the section below that explains it — this table is an index, not a replacement for reading that section the first time you use something.
Entry point and output
What every script defines, and the three ways it can answer with output.
| Signature | Summary | Availability |
|---|---|---|
fn custom(ints, texts) | the entry point every script defines: the directive's integer and string arguments, returning the bytes to emit | — |
emit_source(text) | return text as assembly source for the assembler to expand at the call site, rather than as bytes | — |
Files and paths
Reading assets from disk. Relative paths resolve against the source file's directory; @/ resolves from the project root.
| Signature | Summary | Availability |
|---|---|---|
open_file(path[, mode]) | open a file — "r" to read, no mode to read and write, creating or truncating it | needs fs (absent in the WebAssembly build) |
file.read_blob([n]) | read the whole file, or n bytes, as a blob | needs fs (absent in the WebAssembly build) |
file.read_string([n]) | read the whole file, or n bytes, as a string | needs fs (absent in the WebAssembly build) |
file.write(data) | write a blob or string to the file, returning the byte count | needs fs (absent in the WebAssembly build) |
file.seek(pos) | move the file's read/write cursor | needs fs (absent in the WebAssembly build) |
read_blob(path) | read a whole file as a blob in one call | needs fs (absent in the WebAssembly build) |
Images (PNG)
Decoding a PNG once, then reading it by pixel, by tile, or by matching whole cells against a bank.
| Signature | Summary | Availability |
|---|---|---|
decode_png(blob) | decode PNG bytes into an image handle | — |
decode_png_file(path) | read and decode a PNG in one call | needs fs (absent in the WebAssembly build) |
image | a decoded image; a shared handle, so passing it around copies nothing | — |
img.width | the image width in pixels | — |
img.height | the image height in pixels | — |
img.pixels | every channel as a flat R, G, B, A array, row-major — built fresh on each read, so prefer the accessors | — |
img.r(x, y) | the red channel of a pixel — its shade, for the grayscale images scripts use | — |
img.pixel(x, y) | a whole pixel as [r, g, b, a] | — |
img.tile(col, row, w, h) | a w×h block's red channels, row-major, at grid position (col, row) | — |
bank.find_cell(src, col, row, w, h) | the index of the bank cell drawing the same thing as that cell of src, or -1 | — |
bank.cell_equals(index, src, col, row, w, h) | whether bank cell index draws that cell of src | — |
bank.nearest_cell(src, col, row, w, h) | the closest bank cell by summed shade difference — never -1 | — |
Palette
Turning shade values into fixed-palette indices.
| Signature | Summary | Availability |
|---|---|---|
quantize(value, thresholds) | snap a value — or a whole array of them — to a palette index by counting the ascending thresholds it reaches | — |
nes_shade(value) | the NES four-shade case of quantize (thresholds [43, 128, 213]), returning 0–3; also takes an array | — |
Structured data
The host parses the document; the script walks it. Rhai is fast enough to orchestrate a parse and far too slow to be one.
| Signature | Summary | Availability |
|---|---|---|
parse_xml(source) | parse an XML document held in a string, returning its root element | — |
parse_xml_file(path) | read and parse an XML document in one call | needs fs (absent in the WebAssembly build) |
xml_node | a parsed XML element; a shared handle, like an image | — |
node.name | the element's name, verbatim | — |
node.attrs | every attribute as a name → value map, sorted by name rather than document order | — |
node.attr(name) | one attribute's value, or () when it is not set | — |
node.children | the child elements, as an array — text is not a child | — |
node.text | the element's own text with entities decoded, or () when it has none | — |
node.find(name) | the first child element with that name, or () | — |
node.find_all(name) | every child element with that name, as an array | — |
parse_json(source) | parse a JSON document held in a string into native maps, arrays, and scalars | — |
parse_json_file(path) | read and parse a JSON document in one call | needs fs (absent in the WebAssembly build) |
parse_csv(text[, options]) | parse a CSV/TSV document held in a string, returning it as a table | — |
parse_csv_file(path[, options]) | read and parse a CSV/TSV document in one call | needs fs (absent in the WebAssembly build) |
csv_table | a parsed CSV/TSV document; a shared handle, like an image | — |
csv_row | one data row of a csv_table, indexable by column name (row["x"]) or position (row[0]) | — |
table.headers() | the column names, in file order | — |
table.rows() | every data row, each indexable by column name or position | — |
table.len() | the row count | — |
Numbers, strings, and blobs
Decoding delimited numbers in one native call, and the small string and blob gaps Rhai's standard library leaves.
| Signature | Summary | Availability |
|---|---|---|
parse_int_list(text, delim[, radix]) | decode a whole delimited column of integers in one native call, skipping empty fields | — |
to_char(value) | a one-character string for a Unicode scalar, for building strings out of bytes | — |
s.trimmed() | a trimmed copy of a string — the non-mutating form of trim(), which returns () | — |
format_hex(value, width) | assembly's own hex spelling: $-prefixed and zero-padded to width | — |
Randomness
Procedural noise and randomized tables. A script that draws random values is never cached, and never reproducible.
| Signature | Summary | Availability |
|---|---|---|
rand([min, max]) | a random integer, or one in the inclusive range min..=max | needs rand (absent in the WebAssembly build) |
rand_float() | a random float in 0.0..1.0 | needs rand (absent in the WebAssembly build) |
rand_bool([p]) | a random true/false, or true with probability p | needs rand (absent in the WebAssembly build) |
array.shuffle() | shuffle an array in place | needs rand (absent in the WebAssembly build) |
array.sample([n]) | one random element, or n of them | needs rand (absent in the WebAssembly build) |
Macros or scripts?
nessemble offers two ways to generate code and data from your own logic:
macros (built in) and custom-pseudo-op scripts (this page).
They overlap, but each is better at different things.
Reach for a macro when the task is assembly-shaped — repeating a sequence of instructions, filling in a few parameters, or defining local labels that the rest of your program can branch to:
- It emits real assembly: instructions, labels (including
\@-uniquified ones), and directives, expanded inline where you call it. - Its parameters are numbers or label variables (
\1,\2, …), and\#/\@cover argument counts and per-call unique ids. - It needs no external file, no
--pseudomapping, and nothing outside the assembler — it is part of the source.
Reach for a script when the task is computational — anything that would be painful or impossible to express as assembly text:
- Non-trivial math (easing curves, checksums, trig tables), string handling, or data transforms that macros' single-precedence integer expressions can't do.
- Reading assets from disk and converting them (PNG → CHR, WAV → DPCM, arbitrary binary blobs) via the filesystem and PNG helpers.
- Randomized or procedurally generated data (see Random numbers).
- Logic you'd rather write, test, and reuse as a real program.
A rule of thumb: if you're mostly stamping out assembly, use a macro; if you're
mostly computing bytes, use a script. The two compose freely — a macro can wrap
a .custom-style directive, and a script emits raw bytes that assembly around it
refers to by label. Note the trade-offs: macro-created labels are hidden from the
list file unless you pass --mlist, and scripts run arbitrary
code with filesystem access, so only run ones you trust.
Usage
Pass the --pseudo flag to point at a mapping file that associates each custom
directive with a script.
Example pseudo.txt:
.foo = foo.rhai
Example example.asm:
.foo 1, 2, 3
To assemble:
nessemble example.asm --pseudo pseudo.txt
A script path in the mapping file is resolved relative to the mapping file's
own directory, so a pseudo.txt and the scripts it names can live together and
be pointed at from anywhere. Bundled scripts installed with nessemble scripts
(into ~/.nessemble/scripts) are resolved via ~/.nessemble/scripts/scripts.txt
and need no --pseudo flag.
Writing a script
A script defines a function named custom that receives the directive's
arguments and returns the bytes to emit:
fn custom(ints, texts) {
// ...
}
intsis an array of the integer arguments.textsis an array of the string arguments (quotes already removed).- Return the emitted bytes as an array of integers (each taken
& 0xFF), a blob, or a string (its bytes are emitted). Returning()emits nothing. Wrap a string inemit_source(...)instead to return assembly source for the assembler to expand, rather than bytes.
Execution model
custom is called directly; the host never evaluates the rest of the script
first. That means a statement sitting outside any fn never runs:
const SCALE = 3;
fn custom(ints, texts) {
let out = [];
for i in ints { out.push(i * SCALE); }
out
}
fails at build time with Variable not found: SCALE — on the line inside
custom that reads it, not on the const line, because the const line
never executes. Put anything a script needs at call time inside custom
itself (or a fn it calls), not at the top level.
Example
A .product directive that multiplies its integer arguments:
fn custom(ints, texts) {
let product = 0;
let first = true;
for i in ints {
if first { product = i; first = false; } else { product *= i; }
}
[product % 256]
}
.product 1, 2, 3 ; emits a single byte: 6
String arguments
String arguments arrive (with quotes removed) in texts:
.foo "easeInQuad", 0, 16
fn custom(ints, texts) {
let name = texts[0]; // "easeInQuad"
// ...
}
Declaring file arguments
A script's string argument is opaque to the assembler: .tilemap "map.png" could
be a filename, an easing name, or a label. Prefix it with file:// to say that it
names an input file:
.tilemap "file://map.png", "file://tiles.png"
The script sees the path with the prefix stripped — texts[0] is "map.png"
— so nothing about the script changes, and adding the declaration to a call site
is a one-word edit. What it buys:
- The file is checked before the script runs. A missing declared file is
reported against the directive, on its own line, the same way a missing
.incbinfile is — instead of whatever error the script happens to throw when itsopen_filefails. The script is not run at all in that case. - The path is visible to tooling. Editors resolve a declared path, so cmd/ctrl-clicking it opens the file, hovering it shows where it resolved to, and typing inside the quotes completes filenames — see editor support. None of that requires running the script.
Relative paths resolve against the source file's directory — the same base as
the script's own file reads — and an absolute path
(file:///home/me/assets/map.png) is used as-is. A declared argument prefixed
@/ instead resolves from the
project root:
.tilemap "file://@/art/map.png"
The script still sees a plain path in texts[0], just an absolute one this
time — @/ resolution happens once, before the file-existence check, so
texts[0] is already "/path/to/project/art/map.png" by the time custom
runs. Nothing about the script changes; read_blob(texts[0]) works exactly as
it does for a source-relative path.
A script's own file reads honour @/ too — see
Filesystem access — so declaring an argument is about the
existence check and editor support above, not about unlocking @/ for a path
the script builds itself.
Declaring is optional and per-argument. A script that treats a missing file as optional — falling back to a default when it isn't there — should leave the prefix off that argument and keep its own fallback, since a declared file that is absent is an error. The same prefix is accepted on the built-in filename directives, where it is redundant but harmless, so a project can spell every path the same way.
Errors
Signal an error with throw. The thrown message becomes the assembler
diagnostic:
fn custom(ints, texts) {
if texts.is_empty() {
throw "No arguments provided";
}
[]
}
Emitting assembly source
A script can return emit_source(text) instead of bytes: text is assembly
source, expanded inline at the directive's own call site — lexed, parsed,
and executed exactly as if it had been written there — rather than emitted as
raw bytes. This is the escape hatch for a directive whose job is to generate
assembly, not compute a fixed byte sequence: a data table expressed as real
.db/.dw lines with labels a caller can reference, or a repeated pattern a
script would rather spell as instructions than as opcode bytes.
fn custom(ints, texts) {
let out = "";
for i in 0..ints[0] {
out += "frame" + i + ": .db " + (i * 8) + "\n";
}
emit_source(out)
}
.frames 3 LDA frame1 ; a label the emitted source defined, used right after it
A plain returned string already means "emit these bytes" (matching the
reference Lua host's convention) — emit_source is what distinguishes
"this is source to expand" from that, so wrap the string rather than
returning it directly.
A few things follow from the source being expanded inline, not in a separate file:
- Labels and constants the emitted source defines are real symbols,
usable by code before or after the directive, exactly like a label defined
in a
.macrobody. Like a macro-defined label, one from emitted source is hidden from the-llist file unless--mlistis given. - Diagnostics, spans, and coverage are attributed to the directive's own
line, not to a position inside the emitted text — there is no file for an
editor to open at "line 3 of whatever
.framesreturned", so a parse error in the emitted source is reported as.frames's own error, on.frames's own line. .include,.inestrn,.macro, and.macrodefcannot appear in emitted source. Those are preprocessor constructs —.include/.macrosplice text before parsing, at a stage that has already finished by the time a script runs — and using one inemit_source's text is a directive-specific error rather than the more confusing "unknown custom pseudo-op" a bare parse of.includewould otherwise give.- A script that emits source is never cached. The assembler has to re-expand the source on every build regardless — it has assembly-time side effects (symbols, byte emission) an on-disk cache cannot replay — so caching would only ever have saved the (comparatively cheap) expansion step, not the script's own execution.
- Emitted source can itself invoke a custom directive, including one that
emits source again — it dispatches exactly like any other directive. Nested
emit_sourcemore than ten levels deep is a hard error rather than a stack overflow.
Filesystem access
Scripts can read and write files through the
rhai-fs package, so a directive can pull bytes from
disk instead of only computing them. The main entry point is open_file:
open_file(path, "r")opens a file for reading;open_file(path)opens it for reading and writing, creating or truncating it.- On the returned file handle:
read_blob()/read_string()return the whole file,read_blob(n)/read_string(n)readnbytes,write(blob_or_string)writes bytes and returns the count, andseek(pos)moves the cursor. read_blob(path)is a one-call shorthand for reading a whole file — it returns the file's bytes as a blob, equivalent toopen_file(path, "r").read_blob().
Relative paths resolve against the source file's directory — the same base
as .include and the .inc* importers — absolute paths are used as-is, and a
path prefixed @/ resolves from the
project root, the same as everywhere
else @/ is honoured:
fn custom(ints, texts) {
read_blob("@/assets/shared.bin")
}
open_file, read_blob, decode_png_file, parse_xml_file, and
parse_json_file all resolve @/ identically — no one of them is left
behaving differently from the others. A @/ path is an error when no project
root could be determined, naming the sigil rather than falling back to the
source file's directory.
A .embed "file" directive that emits a file's bytes verbatim:
fn custom(ints, texts) {
open_file(texts[0], "r").read_blob()
}
.embed "logo.chr" ; emits the raw bytes of logo.chr
Filesystem access is not sandboxed. A script can read or write any path the
nessembleprocess can. Only run pseudo-op scripts you trust, as with any build tooling.
Decoding PNGs
decode_png(blob) decodes PNG bytes (typically from open_file(...).read_blob())
into an image:
let img = decode_png(open_file("sprite.png", "r").read_blob());
decode_png_file(path) is a one-call shorthand for the common case, equivalent
to decode_png(read_blob(path)):
let img = decode_png_file("sprite.png");
An image exposes:
img.width— the image width in pixels (integer).img.height— the image height in pixels (integer).img.pixels— a flat array ofwidth * height * 4integers, four per pixel inR, G, B, Aorder, row-major. Pixel(x, y)starts at index(y * width + x) * 4.
decode_png (and decode_png_file) throws if the blob is not a valid PNG.
An image is a handle: assigning it, or passing it to a function, shares the
decoded pixels rather than copying them, so image work can be factored into
helper functions freely. img.pixels, by contrast, builds a fresh array of every
channel each time you ask for it — on a full-resolution image that is tens of
millions of values, so prefer the accessors below, which read the decoded pixels
directly.
Pixel accessors
Rather than compute (y * width + x) * 4 offsets by hand, an image exposes
accessor methods:
img.r(x, y)— the red channel of pixel(x, y). The images these scripts work with are grayscale (R == G == B), so this is the pixel's shade value.img.pixel(x, y)— the whole pixel as a[r, g, b, a]array.img.tile(col, row, tw, th)— thetw×thblock at tile coordinate(col, row)(i.e. pixels[col*tw, (col+1)*tw)×[row*th, (row+1)*th)) as a flat, row-major array of red-channel (shade) values.
All three throw if the coordinates fall outside the image. Using them, the red-channel-of-a-tile example above becomes a single call:
fn custom(ints, texts) {
decode_png_file(texts[0]).tile(0, 0, 8, 8) // top-left 8x8 tile's shades
}
Cell matching
Converting a picture into tile indices means asking, over and over, which cell
of this sheet does this cell of my image draw? Three methods answer that
natively, against a bank image gridded into w×h cells left to right, top
to bottom (floor(width / w) by floor(height / h) of them — a ragged right or
bottom edge is not a cell, exactly as img.tile grids an image):
bank.find_cell(src, col, row, w, h)— the index of the bank cell that draws the same thing as thew×hcell at grid position(col, row)ofsrc, or-1if none does. When several bank cells are identical, the lowest index wins.bank.cell_equals(index, src, col, row, w, h)— whether bank cellindexdraws that same cell. Anindexoutside the bank is simplyfalse; use this to validate an index you already have without re-scanning.bank.nearest_cell(src, col, row, w, h)— the closest bank cell by summed per-pixel shade difference, for a cell with no exact match. Ties go to the lowest index, and it always returns an index.
All three compare NES shade indices — each pixel's red channel put through
the same snapping nes_shade uses — and ignore green,
blue and alpha. So bank.find_cell(src, col, row, w, h) agrees exactly with
scanning the bank for nes_shade(bank.tile(…)) == nes_shade(src.tile(…)), and
pixels differing only below the snapping thresholds (say, in the low nibble of a
byte, where a script can stash its own per-cell data) compare equal.
A .tilemap "map.png", "tiles.png" directive emitting one index per 8×8 cell,
falling back to the closest tile when a cell isn't in the sheet:
fn custom(ints, texts) {
let map = decode_png_file(texts[0]);
let tiles = decode_png_file(texts[1]);
let out = [];
for row in 0..(map.height / 8) {
for col in 0..(map.width / 8) {
let i = tiles.find_cell(map, col, row, 8, 8);
if i < 0 { i = tiles.nearest_cell(map, col, row, 8, 8); }
out.push(i);
}
}
out
}
A cell position outside src, a zero or negative cell size, or a bank too small
to hold one whole cell throws an error naming the call.
Palette quantization
quantize(value, thresholds) snaps a value to a palette index by counting how
many of the ascending thresholds it reaches — useful for turning a grayscale
shade into a fixed-palette index. It also accepts an array of values and
returns an array of indices, so it pairs directly with img.tile:
// [43, 128, 213] are the midpoints between the four NES shades (0, 85, 170, 255).
let shades = quantize(img.tile(0, 0, 8, 8), [43, 128, 213]);
nes_shade(value) is that NES four-shade case with the thresholds built in
(equivalent to quantize(value, [43, 128, 213])), returning 0–3. It also
accepts an array:
let shades = nes_shade(img.tile(0, 0, 8, 8));
Parsing structured data
Assets aren't always images. A map editor, a tracker, or a spreadsheet usually
saves XML or JSON, and parse_xml/parse_xml_file and parse_json/
parse_json_file read them the same way decode_png/decode_png_file reads a
PNG: the host does the parsing, and the script only ever walks an
already-parsed document. A Rhai script tokenizing a document by hand is roughly
10× slower than an entire out-of-process conversion in a compiled language —
these functions exist so no script has to.
XML
parse_xml_file(path) (and parse_xml(source), for a string already in hand)
returns the root element as a node with:
.name— the element name..attrs— a map of attribute name → string value (sorted by name, not document order — see the note below)..attr(name)— an attribute's value, or()if it isn't set. The common case, and unaffected by the.attrsordering note..children— an array of child elements (not text)..text— the element's own text content, entities decoded, or()if it has none. Whitespace used purely for indentation between child elements is not filtered out — call.trimmed()on it if you only want meaningful text..find(name)— the first child element with that name, or()..find_all(name)— every child element with that name, as an array.
fn custom(ints, texts) {
let doc = parse_xml_file(texts[0]);
let out = [];
for row in doc.find_all("row") {
out += parse_int_list(row.attr("data"), ",");
}
out
}
Scope is deliberately narrow: elements, attributes, text, and entities (the
five predefined ones, plus numeric character references like /A).
No namespaces, no XPath, no schema validation — and no DTD processing: a
<!DOCTYPE is a parse error, not something silently skipped or expanded, since
resolving external entities on a script's behalf is exactly the shape of an XXE
vulnerability. Errors name the file and the line/column where parsing failed.
.attrsis a plain Rhai map, which is always key-sorted — Rhai has no insertion-ordered map type..attr(name)(a direct lookup) is unaffected; only a script that iterates.attrsas a whole to reproduce document order would notice.
JSON
parse_json_file(path) / parse_json(source) convert a document straight into
native Rhai values: an object becomes a map, an array becomes an array, and
scalars become the matching int/float/string/bool/().
fn custom(ints, texts) {
let doc = parse_json_file(texts[0]);
let out = [];
for tile in doc.tiles {
out.push(tile.id);
}
out
}
A syntax error's message already names its line and column.
CSV
parse_csv_file(path) (and parse_csv(text), for a string already in hand)
read row-per-record data the way parse_xml_file reads tree-shaped data,
returning a csv_table:
table.headers()— the column names, in file order.table.rows()— every data row (the header row is not one), as an array ofcsv_row.table.len()— the row count.for row in table { ... }— iterates.rows().
Each csv_row is indexable both by column name and by zero-based
position:
fn custom(ints, texts) {
let table = parse_csv_file(texts[0]);
let out = [];
for row in table {
out.push(parse_int(row["max_speed_x"], 16)); // by column name
out.push(parse_int(row[1], 16)); // or by position
}
out
}
An unknown column name or an out-of-range position throws, rather than
returning () the way xml_node.attr(name) does — a CSV row's columns are
fixed by the table's own header, so a bad index is almost always a typo worth
catching immediately, not a value a script might legitimately want to check
for.
Fields are RFC 4180-style: a field starting with " is quoted, so it may
contain embedded delimiters and newlines, and "" inside one decodes to a
literal ". Blank lines (zero characters between line endings) are skipped
rather than becoming a row of empty fields. Every field is a plain string —
no numeric coercion, and no automatic trimming (an XML element's .text
is left just as mechanically un-trimmed) — reach for
.trimmed() if a field might carry incidental
whitespace.
The delimiter is , by default; pass an options map to use another
single-character delimiter, e.g. for TSV:
let table = parse_csv_file(texts[0], #{ delimiter: "\t" });
An unrecognized option key is an error, so a typo (delimeter) is caught at
the call site rather than silently parsing as comma-delimited. A row whose
field count disagrees with the header is a parse-time error naming the file,
line, and the column it disagrees about.
Bulk numeric decoding
Structured formats store grids and arrays as delimited text, often thousands of
values at a time. parse_int_list(text, delim) (and the three-argument form,
parse_int_list(text, delim, radix)) decode a whole column in one native call
instead of one interpreter iteration per value: split on the literal
delimiter, trim whitespace, skip empty fields, and parse the rest.
let values = parse_int_list("1, 2,,3 ,", ","); // [1, 2, 3]
let bytes = parse_int_list("ff,1a", ",", 16); // [255, 26]
String and hex helpers
to_char(value)— a one-character string for the Unicode scalarvalue, for building a string out of bytes read from a blob (s += to_char(b);)." text ".trimmed()— a trimmed copy of a string. The stocktrim()mutates in place and returns(), solet t = s.trim();binds unit; reach fortrimmed()when you want the result as a value.format_hex(value, width)— assembly's own hex spelling:$-prefixed, zero-padded.format_hex(255, 2)is"$FF",format_hex(0x1A, 4)is"$001A".parse_int(str, radix)(the two-argument form) andblob.as_string()already exist in Rhai's own standard library and need nothing from this crate — reach for them directly.
Random numbers
Scripts can draw random values through the
rhai-rand package — handy for procedural noise,
scrambled data tables, or randomized test fixtures:
rand()— a random integer.rand(min, max)— a random integer in the inclusive rangemin..=max.rand_float()— a random float in0.0..1.0.rand_bool()— a randomtrue/false.rand_bool(p)—truewith probabilityp(a float in0.0..1.0).- On arrays:
array.shuffle()shuffles in place, andarray.sample()/array.sample(n)draw one ornrandom elements.
A .noise directive that emits \1 random bytes:
fn custom(ints, texts) {
let out = [];
for i in 0..ints[0] {
out.push(rand(0, 255));
}
out
}
.noise 16 ; emits 16 random bytes
Random output is not reproducible. Each assembly draws fresh values, so a script using these functions produces a different ROM every run. Keep them out of builds that must be deterministic (or seed your own generator in the script instead). A script that draws random values is never cached — which is what keeps it working.
The random functions are available on native builds. They are absent from the WebAssembly build (which has no system entropy source), where calling one raises a "function not found" error — the same way filesystem access is unavailable there.
Caching
A script that crunches a PNG into CHR data should cost that crunch once per change
to the PNG, not once per build. nessemble therefore remembers what each custom
directive emitted, in two layers:
- Within one build, a directive's script runs once, not once per assembler pass. This is unconditional.
- Across builds, the emitted bytes are stored in
~/.nessemble/cacheand reused while nothing the script depended on has changed.
Nothing needs configuring, and scripts need no changes: the host records every
file a script opens — through open_file, read_blob, decode_png_file,
parse_xml_file, or parse_json_file — and remembers those as the run's
inputs. A script that computes a filename, reads a palette nobody passed it, or
follows a reference from inside one parsed document to another, is covered.
An entry is reused only when all of the following still hold:
- the script itself is unchanged (a
--pseudomapping pointing at a different script is a different entry too), - every file the script read is unchanged,
- the directive's arguments, the directory it was called from, and the
project root (if any) are the same —
two builds that agree on everything else but disagree on
--rootdo not share an entry, since a@/-prefixed path the script resolves itself could read a different file under each, - and the
nessembleversion is the same — the host's helpers define the output, so a new release starts from an empty cache.
"Unchanged" means the same size and modification time. That is what a build
tool can check in microseconds, and it catches every ordinary edit; a git checkout that rewrites timestamps costs a needless re-run rather than a wrong
result. The one gap: an edit that keeps a file's exact byte size and lands inside
the same timestamp tick as the previous one can go unnoticed. If a build ever looks
stale, --no-cache bypasses the cache entirely and
nessemble cache clear empties it.
Prewarming runs independent scripts concurrently
Before assembling, nessemble scans the program for custom-directive
invocations whose arguments it can already work out — a directive whose
integer arguments are plain numbers (no forward-referenced label) and whose
string arguments are either undeclared or name a file that exists. Every
invocation is independent by construction, so the ones it finds are resolved
concurrently, across as many CPU cores as are available, filling the cache
before the sequential assembly passes read from it. On a script-heavy build —
several .tilemap/.incpng-style directives, each decoding its own PNG — this
overlaps work that used to run one script at a time.
This is transparent to almost every script: nothing about what a directive computes changes, only when it computes it, and the same "once per build, memoized across passes" guarantee still holds for the bytes that actually reach the ROM. Two things follow from prewarming happening ahead of, and independently of, that per-build memoization:
- A script that writes a file, draws randomness, or otherwise does
something the never-cached list covers is never
prewarmed — running it an extra, uncounted time would be a real side
effect, not merely wasted work, so
nessemblechecks for exactly that (without running the script) before ever including it. - A directive whose arguments are not yet knowable — a forward-referenced label, most commonly — is left for the sequential passes, exactly as before prewarming existed; nothing about it changes.
Directive scripts remain independent of each other in every way that matters (each runs on its own interpreter instance, its own cache reads and writes), so no script needs to change to benefit from this. The one thing worth knowing: if a script reaches outside what the host tracks — writing to a fixed path some other tool also touches, say — assume it can now run concurrently with other invocations, not only with itself across builds.
What is never cached
Some scripts must really run every time, and are detected and excluded automatically:
- Random output —
rand,rand_float,rand_bool,shuffle,sample. The point of these is to differ per build. - Writing a file — the write is the effect, and replaying stored bytes would skip it.
- Listing a directory (
open_dir) — a listing's contents are not described by any per-file check. importing a module — a module's source is invisible to the recorder, so such a script is refused rather than tracked incompletely.emit_sourceoutput — the assembler must re-expand it fresh on every build regardless (it has assembly-time side effects a cache cannot replay), so nothing would be saved by storing it.
The check is deliberately cautious: it looks at what a script could do, so a
rand() in a branch that never runs is enough to keep the script out of the
cache. Being wrong that way costs one script execution; being wrong the other way
would emit a stale ROM.
nessemble coverage --scripts also bypasses the cache, since a cached result
executes no lines and would report a covered script as uncovered.
Runaway-script guard and timing
Every script runs on a Rhai engine with an operation-count guard, so a bug
that loops forever fails the build instead of hanging it. The default is
10,000,000 operations — generous for real work, small enough to fail fast on
an accidental infinite loop.
--max-operations overrides it for a build
that legitimately needs more (or wants a stricter cap of its own); 0 means
unlimited, matching Rhai's own convention for that value.
--time-scripts reports, per directive, how many
times it was called, how many of those were cache hits versus real runs, and
the total wall time spent in it — printed to stderr after assembly, busiest
directive first. Prewarmed calls (above)
count too, so the total reflects the real cost of a build, not just its
sequential passes.
Bundled scripts
Running nessemble scripts installs the bundled scripts. The ease script
emits an easing curve as bytes:
.ease "easeInQuad"
Supported easing types include easeInQuad, easeOutQuad, easeInOutQuad,
and the cubic, quint, and bounce variants.
Building
nessemble is a Cargo workspace of pure-Rust crates. Building requires only a
stock Rust toolchain (1.83+).
Build
cargo build --release
The CLI binary is written to target/release/nessemble.
Test
cargo test
The test suite includes hermetic golden-ROM tests that assemble the committed
corpus (tests/corpus/) and compare the output against the golden .rom files
byte-for-byte — no external binary or network access is required.
Cross-compilation
The dependencies are pure Rust, so the five release targets cross-compile cleanly. Add a target and build:
rustup target add i686-unknown-linux-gnu
cargo build --release --target i686-unknown-linux-gnu
| Platform | Target triple |
|---|---|
| macOS | x86_64-apple-darwin |
| Linux amd64 | x86_64-unknown-linux-gnu |
| Linux i386 | i686-unknown-linux-gnu |
| Windows 32-bit | i686-pc-windows-msvc |
| Windows 64-bit | x86_64-pc-windows-msvc |
Packaging
Release artifacts are produced by the CI release workflow
(.github/workflows/release.yml):
.deb(Linux) viacargo-deb..msi(Windows) viacargo-wix..pkg(macOS) viapkgbuild..tar.gz(macOS) — the raw binary, as a signing-free alternative to the unsigned.pkg(which Gatekeeper blocks after download)..vsix(VS Code) — the editor extension ineditors/vscode/, packaged withvsce.
The VS Code extension
cargo run -p xtask -- vsix
Needs npm and npx on PATH; nothing else — the extension is plain
JavaScript with no compile step. The task builds from a staged copy under
target/vsix-build/, stamping the workspace version into the extension
manifest on the way (editors/vscode/package.json holds a 0.0.0-dev
placeholder, so the shipped extension version always tracks the release and no
version string is hand-edited). The result is nessemble_<version>.vsix in the
repository root.
CI packages the extension on every pull request, so a broken manifest or a
stale package-lock.json fails there rather than during a release.
Scripting
Custom pseudo-instruction scripting (Rhai) is enabled by default. To build the CLI without it:
cargo build --release -p nessemble-cli --no-default-features
Translating
nessemble routes every user-facing string through a
Project Fluent catalog, so it can be fully
translated. en-US ships built in and is always the fallback: any message a
locale does not translate falls back to its English value.
Add a locale at runtime
Drop a Fluent file at ~/.nessemble/locales/<lang>.ftl and select it with the
NESSEMBLE_LANG environment variable (or the standard LANG / LC_ALL):
# ~/.nessemble/locales/de.ftl
no-errors = Alles gut
NESSEMBLE_LANG=de nessemble -c game.asm
# -> Alles gut
<lang> should be a valid locale identifier such as de, de-DE, or fr.
Notes for translators
- Copy the built-in
en-US.ftlcatalog and translate the values; message ids are stable and must not be renamed. - Interpolate variables as
{ $name }— the variable names are part of each message's contract. - To keep a trailing space (Fluent trims trailing whitespace), write it as an
explicit literal, e.g.
init-prompt-filename = Filename:{ " " }. - Numbers are interpolated verbatim (no locale grouping).
Only the messages you translate are overridden; everything else falls back to
en-US.
Contributing
Feel free to fork the project and submit pull requests on GitHub:
https://github.com/kevinselwyn/nessemble-rs
Before submitting, please make sure the workspace is clean:
cargo fmt --all
cargo clippy --all-targets
cargo test
Licensing
nessemble-rs is licensed under the GNU General Public License, version 3 or
later.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.