Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

PlatformArtifact(s)
macOSnessemble_<v>.pkg, nessemble_<v>_macos.tar.gz
Linux amd64nessemble_<v>_amd64.deb
Linux i386nessemble_<v>_i386.deb
Windows 32-bitnessemble_<v>_win32.exe, …_win32.msi
Windows 64-bitnessemble_<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 in sub/ that does .include "helper.asm" or .incbin "data.bin" now finds them in sub/.
  • What to check: if a file you .include from 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 .rhai files and update your --pseudo mapping to point at them. A script now defines fn custom(ints, texts) and returns the emitted bytes. See Extending.
  • The bundled ease script is provided as .rhai; run nessemble scripts to install it.
  • Rhai scripts can still read and write files (as the old Lua/Scheme hosts could), via the rhai-fs open_file API; relative paths resolve against the source file's directory. See Filesystem access.
  • Script paths in a --pseudo mapping 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's cwd_path). If your pseudo.txt sits 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>.ftl file and select it with NESSEMBLE_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, --write rewrites each changed file in place and prints its path.
  • -c, --check writes 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, .asm by default) and requires --write or --check.
  • --config <file> uses <file> as the .nessemblerc; --no-config ignores any .nessemblerc and 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:

RuleFlags
require-block-commentA 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-directiveA comment directive naming no known directive (@nessemble-formt), or a known one with wrong arguments (@nessemble-format stride=x).
deprecated-comment-directiveA directive written with a deprecated alias — today, @fmt for @nessemble-format.
ineffective-comment-directiveA 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-signatureA routine annotation that binds to no label (code intervenes, or the file ends), or names the same slot twice.
undeclared-clobberA 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-clobberA 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-docA 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); warn findings do not, unless --max-warnings <n> is exceeded. This is the CI gate.
  • A directory is walked recursively (for the configured extensions, .asm by default), skipping .nessembleignore paths.
  • --quiet reports errors only (suppresses warnings).
  • --config <file> uses <file> as the .nessemblerc; --no-config ignores any .nessemblerc and 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 PHAPLA 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-line targets the next significant line, skipping blank and comment lines. That is what lets it sit above a whole @nessemble-param block and still land on the label, which is where the routine rules report.
  • start opens a region and end closes 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, and require-block-comment — so -next-line above the label reaches them. invalid-routine-signature and 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 is error. 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 --scripts is given.

  • --emulator <fceux|mesen> — the CDL format, default fceux. 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, default all. 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 no kind.

  • --out <path> — output file for a single format, or a directory for all (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 -p Rhai 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 ~/.nessemble scripts are excluded. Available when the binary is built with the coverage feature (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 --emulator is consulted — the shape a CI job without an emulator playthrough can produce. Every script the -p mapping 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 -p mapping 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]
DirectiveApplies toTool
@nessemble-format stride=N[,N,...]the next data run (skipping blank, comment, and label lines)format
@nessemble-coverage-ignore-next-linethe next significant linecoverage
@nessemble-coverage-ignore start | endevery line between the twocoverage
@nessemble-param <slot> [description]the routine below — a register it readslint, editor
@nessemble-returns <slot> [description]the routine below — a slot it defineslint, editor
@nessemble-clobbers <slot>[, ...] | nonethe routine below — what it destroyslint, editor
@nessemble-lint-ignore-next-line [rule[, ...]]findings on the next significant linelint
@nessemble-lint-ignore start | end [rule[, ...]]findings on every line between the twolint
@fmt stride=N[,N,...]deprecated alias of @nessemble-formatformat

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 by lint and 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 startend 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 start in the header to opt a whole file out. A file with nothing left to report is dropped from the report entirely (no SF: 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 start inside an open region, or an end with no start, does nothing and is reported by lint.
  • Rhai scripts (under --scripts) honor both directives, written as // comments.
  • nessemble coverage --no-ignore reports 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 carries ignored / ignoredFiles counts.

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:

SlotMeaning
A, X, Ythe registers
Sthe stack pointer
Pthe whole status register
C, Z, N, V, D, Ione flag — @nessemble-returns C is how a 6502 routine returns a boolean
[symbol]a named memory location, e.g. [oam_cursor]
$NN, $NNNN, $NN-$NNan 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-param and @nessemble-returns take one slot each, followed by a free-text description (a ; in it is part of the description). Repeat the tag for each slot.
  • @nessemble-clobbers takes a list, and means anything not listed is preserved. A returned slot is clobbered by definition and need not be repeated.
  • @nessemble-clobbers none claims the routine preserves everything. That is different from writing no @nessemble-clobbers at 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, and S are verified. Flags are documentation — nearly every instruction disturbs N/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-clobber but never undeclared-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/PLA pair 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 move S, 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": []
}
KeyDefaultMeaning
extensions[".asm"]File extensions formatted during a directory walk.
indentStyle"space"Instruction indent: "space" or "tab".
indentWidth4Spaces per indent level (space style only).
commaSpacingtrue", " between values; false for tight commas.
finalNewlinetrueEnsure the file ends in exactly one newline.
indentDirectivesfalseIndent 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.
alignContinuationstrueAlign 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.
dataPerLine8Values per consolidated .db/.dw/.color line; 0 disables consolidation.
respectStrideHintstrueHonor ; @fmt stride=N[,N,...] comments (see below).
blankLineAfterReturntrueInsert one blank line after every RTS/RTI.
maxConsecutiveBlankLines2Collapse 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=N still works and always will — it is an alias, not a removal. It is reported by the deprecated-comment-directive rule, 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]"]
  }
}
KeyDefaultMeaning
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.

LDX #$08 loop: DEX BNE loop BRK

Numbers

Binary, decimal, octal, hexadecimal, and ASCII character are all valid numbers.

BaseExample AExample B
Binary%0100000101000001b
Decimal6565d
Octal0101101o
Hexadecimal$4141h
ASCII char'A'

Symbols

Mathematical Operators

SymbolDescription
+Add
-Subtract
*Multiply
/Divide
**Exponent
&Bitwise AND
|Bitwise OR
^Bitwise XOR
>>Shift right
<<Shift left
%Modulo

Comparison Operators

SymbolDescription
==Equals
!=Not equals
<Less than
>Greater than
<=Less than or equals
>=Greater than or equals

Special

SymbolDescription
->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:

LDX #$08 loop: DEX BNE loop BRK

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:

LDX #$08 : DEX BNE :- BRK

Mnemonics

All 56 mnemonics are supported:

MnemonicDescription
ADCAdd with Carry
ANDBitwise AND with Accumulator
ASLArithmetic shift left
BITTest bits
BCCBranch on Carry clear
BCSBranch on Carry set
BEQBranch on equal
BMIBranch on minus
BNEBranch on not equal
BPLBranch on plus
BRKBreak
BVCBranch on Overflow clear
BVSBranch on Overflow set
CLCClear Carry
CLDClear Decimal
CLIClear Interrupt
CLVClear Overflow
CMPCompare Accumulator
CPXCompare X register
CPYCompare Y register
DECDecrement memory
DEXDecrement X register
DEYDecrement Y register
EORBitwise exclusive OR
INCIncrement memory
INXIncrement X register
INYIncrement Y register
JMPJump
JSRJump to subroutine
LDALoad Accumulator
LDXLoad X register
LDYLoad Y register
LSRLogical shift right
NOPNo operation
ORABitwise OR with Accumulator
PHAPush Accumulator
PHPPush processor status
PLAPull Accumulator
PLPPull processor status
ROLRotate left
RORRotate right
RTIReturn from Interrupt
RTSReturn from subroutine
SBCSubtract with Carry
SECSet Carry
SEDSet Decimal
SEISet Interrupt
STAStore Accumulator
STXStore X register
STYStore Y register
TAXTransfer Accumulator to X register
TAYTransfer Accumulator to Y register
TSXTransfer Stack Pointer to X register
TXATransfer X register to Accumulator
TXSTransfer X register to Stack Pointer
TYATransfer 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.

MnemonicDescription
AACAND with Accumulator
AAXAND X register with Accumulator
ARRAND with Accumulator
ASRAND with Accumulator
ATXAND with Accumulator
AXAAND X register with Accumulator
AXSAND X register with Accumulator
DCPSubtract 1 from memory
DOPNo operation (x2)
ICSIncrease memory by 1
KILStop program counter
LARAND memory with stack pointer
LAXLoad Accumulator and X register
NOPNo operation
RLARotate one bit left in memory
RRARotate one bit right in memory
SBCSubtract with Carry
SLOShift left one bit in memory
SREShift right one bit in memory
SXAAND Y register with the high byte of address
SYAAND Y register with the high byte of address
TOPNo operation (x3)
XAAUnknown
XASAND X register with Accumulator

Read more about undocumented 6502 opcodes here.

Addressing Modes

ModeExample
ImpliedRTS
AccumulatorROL A
ImmediateLDA #$42
ZeropageSTA <$42
Zeropage, XEOR <$42, X
Zeropage, YLDX <$42, Y
AbsoluteSTA $4200
Absolute, XEOR $4200, X
Absolute, YLDX $4200, Y
IndirectJMP [$4200]
Indirect, XLDA [$42, X]
Indirect, YSTA [$42], Y
RelativeBEQ label

nessemble uses 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

FunctionDescription
HIGH()Get high byte of address
LOW()Get low byte of address
BANK()Get bank of address

Pseudo-Instructions

Pseudo-InstructionDescription
.asciiConvert ASCII string to bytes
.byteAlias for .db
.checksumCalculate crc32 checksum
.chrSet CHR bank index
.colorConvert hex color to NES color
.dbDefine 8-bit byte(s)
.defchrDefine CHR tile
.dephaseEnd a .phase block
.dwDefine 16-bit word(s)
.elseElse condition of an .if/.ifdef/.ifndef statement
.endenumEnd .enum
.endifEnd .if/.ifdef/.ifndef statement
.endmEnd .macrodef
.enumStart enumerated variable declarations
.fillFill with bytes
.fontGenerate font character tile
.hibytesOutput only the high byte of 16-bit word(s)
.ifTest if condition
.ifdefTest if variable is defined
.ifndefTest if variable has not been defined
.incbinInclude binary file
.includeInclude assembly file
.incpalInclude palette from PNG
.incpngInclude PNG
.incrleInclude binary data to be RLE-encoded
.incwavInclude WAV
.ines2Emit a NES 2.0 header
.ines4scriNES four-screen VRAM flag
.inesbatiNES battery / persistent memory flag
.ineschriNES CHR count
.ineschrnvramNES 2.0 battery CHR-RAM size
.ineschrramNES 2.0 CHR-RAM size
.inesconsoleNES 2.0 console type
.inesexpansionNES 2.0 default expansion device
.inesmapiNES / NES 2.0 mapper number
.inesmiriNES mirroring
.inesmiscromNES 2.0 miscellaneous ROM count
.inespc10iNES PlayChoice-10 flag
.inesprgiNES / NES 2.0 PRG count
.inesprgnvramNES 2.0 battery PRG-RAM size
.inesprgramiNES / NES 2.0 PRG-RAM size
.inessubmapNES 2.0 submapper number
.inestimingNES 2.0 CPU/PPU timing
.inestrniNES trainer include
.inestviNES / NES 2.0 TV system
.inesvsiNES VS Unisystem flag
.inesvshwNES 2.0 VS System hardware type
.inesvsppuNES 2.0 VS System PPU type
.lobytesOutput only the low byte of 16-bit word(s)
.macroCall macro
.macrodefStart macro definition
.orgOrganize code
.outOutput debugging message
.phaseAssemble code for a different run-time address
.prgSet PRG bank index
.randomOutput random byte(s)
.rssetSet initial value for .rs declarations
.rsReserve space for variable declaration
.segmentSet code segment
.wordAlias 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:

.ascii "When, in disgrace with fortune and men's eyes"

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:

start: LDA #$01 STA <$02 .checksum start

.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:

.color $FF0000

.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:

.db $12, $34

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:

.defchr 333333333, 300000003, 300000003, 300000003, 300000003, 300000003, 300000003, 333333333

.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:

.dw $1234, $45678

.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:

.fill 16

.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 from START to [, 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:

.font 'A', 'G'

.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:

.hibytes $1234, $5678

.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:

ArgumentResolves 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:

ColorNameRGBHex
Black0, 0, 0#000000
Dark Grey85, 85, 85#555555
Light Grey170, 170, 170#AAAAAA
White255, 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:

ValueDescription
00-80Read another byte and write it to the output N times
81-FECopy N-128 bytes from input to output
FFEnd 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:

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 count size = 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 count size = 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.

ValueConsole type
0Nintendo NES / FC
1VS System
2PlayChoice-10
3Extended (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)
ValueMirroring
0Horizontal
1Vertical

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 count size = 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 count size = 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.

ValueTiming
0RP2C02 (NTSC)
1RP2C07 (PAL)
2Multi-region
3UMC 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
ValueTV systemFlags 9Flags 10
0NTSC00
1PAL12

Usage:

.inestv SYSTEM
  • SYSTEM - Number, required. 0 for NTSC, 1 for 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:

.lobytes $1234, $5678

.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 .rs declarations.

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:

.rsset $0000

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. PRG or CHR.
  • [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-InstructionDescription
.easeGenerates 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):

.ease "easeOutBounce", 0, $20, $40

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:

.macrodef TEST_MACRO LDA #$00 STA $2005 STA $2005 .endm

.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 lint CLI appear inline, at a gentler severity (Information/Hint) and tagged with the nessemble-lint source so they read as suggestions distinct from assembler errors. They honor the project's .nessemblerc lint config (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-clobbers omits 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 .include graph (no configuration needed) and reflects unsaved edits across files. The open workspace folder also doubles as the project root for @/ paths, overriding the usual .nessemblerc walk-up the same way the CLI's --root does — 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-ignore pre-filled with start and with end — each with its documentation. A comment directly above an undocumented label also offers a routine signature block, which scaffolds @nessemble-param / -returns / -clobbers in one insertion. Inside a filename argument — the path of .include, .incbin, .incpng, .incpal, .incrle, .incwav, .inestrn, or any argument written with a file:// prefix — filenames from that directory are offered instead, filtered to what the directive can use (.incpng offers PNGs, .include offers 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 format CLI 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 documentation modifier, 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 a file:// 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 my Y?" is answered without leaving the call site. Hovering .color previews 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 .nessemblerc added 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-clobbers omits — add that register to the list, keeping the list in canonical order and any trailing prose intact.
  • Inlay hints — a JSR whose 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 its custom function, 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.txt maps that defines no custom(ints, texts) function; custom declared with other than two parameters; a statement written outside every fn (it never runs — custom is called without evaluating the script body first, so a top-level const is Variable not found the moment a .if branch 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_fil is flagged; an unrelated Rhai built-in this catalog doesn't list is not).
  • An outline of the script's functions (custom first), 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.

  1. Make sure nessemble is on your PATH (nessemble --version should print 2.5.0 or newer). If it lives somewhere off PATH, point the nessemble.serverPath setting at it.

  2. Download nessemble_<v>.vsix from the releases page.

  3. 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>.vsix
    

    In Cursor, the command is cursor --install-extension.

  4. Open a .asm file. Diagnostics, lint hints, completion, hover, formatting, semantic highlighting, outline, go-to-definition, and rename all work immediately; the server starts on the first nessemble file you open. Opening a .rhai script a --pseudo mapping 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

SettingDefaultWhat it does
nessemble.serverPathnessemblePath 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.serveroffLog 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 .asm with VS Code's built-in asm language, use "[asm]".
  • editor.formatOnSave performs the on-save formatting.
  • editor.defaultFormatter names the provider to use — the extension identifier, <publisher>.<name>. Setting it avoids the "multiple formatters" prompt when another extension also claims .asm files.

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 the lsp feature) still accepts nessemble 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 *.txt mapping file in the workspace (or next to the open file) whose .name = script entries 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 .rhai script'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 the scripting feature, which is also on by default; --no-default-features --features lsp drops them the same way it drops lsp's own features from a build with no scripting.

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.

SignatureSummaryAvailability
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.

SignatureSummaryAvailability
open_file(path[, mode])open a file — "r" to read, no mode to read and write, creating or truncating itneeds fs (absent in the WebAssembly build)
file.read_blob([n])read the whole file, or n bytes, as a blobneeds fs (absent in the WebAssembly build)
file.read_string([n])read the whole file, or n bytes, as a stringneeds fs (absent in the WebAssembly build)
file.write(data)write a blob or string to the file, returning the byte countneeds fs (absent in the WebAssembly build)
file.seek(pos)move the file's read/write cursorneeds fs (absent in the WebAssembly build)
read_blob(path)read a whole file as a blob in one callneeds 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.

SignatureSummaryAvailability
decode_png(blob)decode PNG bytes into an image handle
decode_png_file(path)read and decode a PNG in one callneeds fs (absent in the WebAssembly build)
imagea decoded image; a shared handle, so passing it around copies nothing
img.widththe image width in pixels
img.heightthe image height in pixels
img.pixelsevery 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.

SignatureSummaryAvailability
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 03; 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.

SignatureSummaryAvailability
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 callneeds fs (absent in the WebAssembly build)
xml_nodea parsed XML element; a shared handle, like an image
node.namethe element's name, verbatim
node.attrsevery 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.childrenthe child elements, as an array — text is not a child
node.textthe 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 callneeds 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 callneeds fs (absent in the WebAssembly build)
csv_tablea parsed CSV/TSV document; a shared handle, like an image
csv_rowone 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.

SignatureSummaryAvailability
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.

SignatureSummaryAvailability
rand([min, max])a random integer, or one in the inclusive range min..=maxneeds rand (absent in the WebAssembly build)
rand_float()a random float in 0.0..1.0needs rand (absent in the WebAssembly build)
rand_bool([p])a random true/false, or true with probability pneeds rand (absent in the WebAssembly build)
array.shuffle()shuffle an array in placeneeds rand (absent in the WebAssembly build)
array.sample([n])one random element, or n of themneeds 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 --pseudo mapping, 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) {
    // ...
}
  • ints is an array of the integer arguments.
  • texts is 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 in emit_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 strippedtexts[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 .incbin file is — instead of whatever error the script happens to throw when its open_file fails. 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 .macro body. Like a macro-defined label, one from emitted source is hidden from the -l list file unless --mlist is 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 .frames returned", so a parse error in the emitted source is reported as .frames's own error, on .frames's own line.
  • .include, .inestrn, .macro, and .macrodef cannot appear in emitted source. Those are preprocessor constructs — .include/.macro splice text before parsing, at a stage that has already finished by the time a script runs — and using one in emit_source's text is a directive-specific error rather than the more confusing "unknown custom pseudo-op" a bare parse of .include would 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_source more 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) read n bytes, write(blob_or_string) writes bytes and returns the count, and seek(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 to open_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 nessemble process 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 of width * height * 4 integers, four per pixel in R, G, B, A order, 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) — the tw×th block 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 the w×h cell at grid position (col, row) of src, or -1 if none does. When several bank cells are identical, the lowest index wins.
  • bank.cell_equals(index, src, col, row, w, h) — whether bank cell index draws that same cell. An index outside the bank is simply false; 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 03. 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 .attrs ordering 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 &#10;/&#x41;). 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.

.attrs is 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 .attrs as 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 of csv_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 scalar value, for building a string out of bytes read from a blob (s += to_char(b);).
  • " text ".trimmed() — a trimmed copy of a string. The stock trim() mutates in place and returns (), so let t = s.trim(); binds unit; reach for trimmed() 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) and blob.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 range min..=max.
  • rand_float() — a random float in 0.0..1.0.
  • rand_bool() — a random true/false.
  • rand_bool(p)true with probability p (a float in 0.0..1.0).
  • On arrays: array.shuffle() shuffles in place, and array.sample() / array.sample(n) draw one or n random 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/cache and 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 --pseudo mapping 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 --root do not share an entry, since a @/-prefixed path the script resolves itself could read a different file under each,
  • and the nessemble version 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 nessemble checks 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 outputrand, 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_source output — 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
PlatformTarget triple
macOSx86_64-apple-darwin
Linux amd64x86_64-unknown-linux-gnu
Linux i386i686-unknown-linux-gnu
Windows 32-biti686-pc-windows-msvc
Windows 64-bitx86_64-pc-windows-msvc

Packaging

Release artifacts are produced by the CI release workflow (.github/workflows/release.yml):

  • .deb (Linux) via cargo-deb.
  • .msi (Windows) via cargo-wix.
  • .pkg (macOS) via pkgbuild.
  • .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 in editors/vscode/, packaged with vsce.

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.ftl catalog 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/>.