The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
23 KiB
NEScript Language Guide
NEScript is a statically-typed, compiled language designed for NES game development. It compiles directly to 6502 machine code packaged as iNES-format ROMs -- no external assembler or tooling required.
This guide covers every language feature with practical examples.
Program Structure
Every NEScript program consists of a game declaration, top-level definitions, and a start declaration.
game "My Game" {
mapper: NROM
mirroring: vertical
}
const SPEED: u8 = 2
var score: u8 = 0
fun helper() -> u8 {
return 42
}
state Title {
on frame {
draw Logo at: (100, 100)
if button.start {
transition Playing
}
}
}
state Playing {
on enter {
score = 0
}
on frame {
// game logic here
}
}
start Title
Game Declaration
The game block is required and must appear first. It names the game and sets hardware configuration.
game "Coin Cavern" {
mapper: NROM
mirroring: vertical
}
Available properties:
| Property | Values | Default |
|---|---|---|
mapper |
NROM, MMC1, UxROM, MMC3 |
required |
mirroring |
horizontal, vertical |
horizontal |
Start Declaration
Exactly one start declaration must exist. It names the initial state entered on power-on.
start Title
Types
NEScript has four primitive types and fixed-size arrays.
Primitive Types
| Type | Size | Range | Description |
|---|---|---|---|
u8 |
1 byte | 0 to 255 | Unsigned 8-bit integer |
i8 |
1 byte | -128 to 127 | Signed 8-bit integer |
u16 |
2 bytes | 0 to 65535 | Unsigned 16-bit integer |
bool |
1 byte | true / false |
Boolean |
Arrays
Arrays are fixed-size, homogeneous, and zero-indexed. The size must be a compile-time constant. Maximum 256 elements.
var enemies: u8[8]
const TABLE: u8[4] = [10, 20, 30, 40]
Type Casting
NEScript has no implicit coercion. All conversions use as:
var a: u8 = 200
var b: u16 = a as u16 // zero-extend: 200
var c: i8 = a as i8 // reinterpret bits
var d: u8 = b as u8 // truncate to low byte
Variables
Variable Declarations
Variables are declared with var and must have an explicit type:
var x: u8 // uninitialized (zeroed on state entry)
var y: u8 = 100 // initialized
var pos: u16 = 0x0400 // 16-bit value
var alive: bool = true
var scores: u8[4] = [0, 0, 0, 0]
Constants
Constants are evaluated at compile time and stored in ROM:
const MAX_ENEMIES: u8 = 5
const SPEED: u8 = 3
const SIN_TABLE: u8[8] = [0, 49, 90, 117, 127, 117, 90, 49]
Enums
Enums declare a named set of u8 constants. Each variant is assigned an
index starting at 0 in declaration order:
enum Direction { Up, Down, Left, Right }
// Up=0, Down=1, Left=2, Right=3
var player_dir: u8 = Up
on frame {
if button.left { player_dir = Left }
if button.right { player_dir = Right }
if player_dir == Down { /* ... */ }
}
Variant names are global — they are flattened into the top-level symbol
table, so a variant cannot share its name with any other constant,
variable, or function (E0501). An enum cannot have more than 256
variants because each is stored as a u8.
Structs
Structs declare composite types with named fields:
struct Vec2 {
x: u8,
y: u8,
}
struct Player {
health: u8,
lives: u8,
}
var pos: Vec2
var hero: Player
on frame {
pos.x = 100
pos.y = 50
hero.health = 3
hero.lives = 5
if button.right { pos.x += 1 }
draw Hero at: (pos.x, pos.y)
}
Fields are laid out contiguously in declaration order. A variable of struct type allocates enough contiguous bytes to hold all its fields; each field is accessible via the dot operator.
Struct literals initialize or assign all fields at once:
struct Vec2 { x: u8, y: u8 }
// as an initializer
var pos: Vec2 = Vec2 { x: 100, y: 50 }
// as an assignment
on frame {
pos = Vec2 { x: 0, y: 0 }
if button.right {
pos = Vec2 { x: pos.x + 1, y: pos.y }
}
}
Inside if, while, and for conditions the struct literal syntax
is reserved for the following block, so wrap the literal in parens if
you ever need one in a condition:
if pos == (Vec2 { x: 0, y: 0 }) { /* ... */ }
In v0.1 only primitive field types (u8, i8, bool) are supported —
nested structs, u16, and array fields are not yet allowed.
Memory Placement Hints
The NES has 256 bytes of zero-page RAM with faster access. You can hint where variables should be placed:
fast var px: u8 // prefer zero-page (faster instructions)
slow var high_score: u16 // prefer upper RAM (saves zero-page space)
var normal: u8 // compiler decides automatically
If zero-page is exhausted and fast variables cannot be placed, the compiler emits error E0301.
Scope
| Scope | Declared In | Lifetime |
|---|---|---|
| Global | Top level | Entire program, permanent RAM allocation |
| State | state block |
Active while state is active; RAM reusable |
| Function | fun block |
Duration of function call |
| Block | if/while |
Enclosing block, shares parent allocation |
Functions
Declaration
Functions use fun, with optional parameters and return type:
fun add(a: u8, b: u8) -> u8 {
return a + b
}
fun reset_score() {
score = 0
}
Inline Functions
The inline keyword hints the compiler to inline the function at call sites:
inline fun clamp(val: u8, max: u8) -> u8 {
if val > max {
return max
}
return val
}
inline is a hint -- the compiler may decline for large functions.
Calling Functions
var result: u8 = add(10, 20)
reset_score()
Restrictions
- No recursion. Both direct and indirect recursion are compile errors (
E0402). - Call depth limit. The default maximum call depth is 8. Exceeding it produces error
E0401.
States
States are the top-level organizational unit. Exactly one state is active at any time.
State Declaration
state Playing {
var timer: u8 = 0 // state-local variable
on enter {
// runs once when entering this state
timer = 60
}
on exit {
// runs once when leaving this state
}
on frame {
// runs every frame (60 Hz) while this state is active
timer -= 1
draw Player at: (player_x, player_y)
}
}
on frame is syntactic sugar for a loop with an implicit wait_frame() at the end. A state can have any combination of on enter, on exit, and on frame.
State Transitions
transition GameOver
Transitions are immediate. The current state's on exit runs, then the target state's on enter runs. The remainder of the current frame handler does not execute.
Expressions
Literals
42 // decimal integer
0xFF // hexadecimal
0b10110001 // binary
1_000 // underscores allowed for readability (if supported)
true // boolean
false // boolean
[1, 2, 3] // array literal
All integer literals must fit in u16 (0-65535). The compiler narrows to the required type at usage.
Arithmetic Operators
| Operator | Description | Example |
|---|---|---|
+ |
Addition | a + b |
- |
Subtraction | a - b |
* |
Multiplication | a * b |
/ |
Division | a / b |
% |
Modulo | a % b |
*, /, and % are available but expensive on the 6502 (software routines). The compiler optimizes power-of-two operations to shifts and warns on non-power-of-two multiply/divide.
Bitwise Operators
| Operator | Description | Example |
|---|---|---|
& |
Bitwise AND | a & 0x0F |
| |
Bitwise OR | a | 0x80 |
^ |
Bitwise XOR | a ^ mask |
~ |
Bitwise NOT | ~a |
<< |
Shift left | a << 2 |
>> |
Shift right | a >> 1 |
Comparison Operators
| Operator | Description | Example |
|---|---|---|
== |
Equal | a == 0 |
!= |
Not equal | a != b |
< |
Less than | a < 10 |
> |
Greater than | a > max |
<= |
Less or equal | a <= 255 |
>= |
Greater or equal | a >= min |
Logical Operators
NEScript uses keyword-based logical operators:
if alive and (health > 0) {
// ...
}
if not paused or force_update {
// ...
}
| Operator | Description |
|---|---|
and |
Logical AND |
or |
Logical OR |
not |
Logical NOT |
Operator Precedence
From highest to lowest:
| Level | Operators | Associativity |
|---|---|---|
| 1 | () grouping |
-- |
| 2 | - (unary), ~, not |
right |
| 3 | *, /, % |
left |
| 4 | +, - |
left |
| 5 | <<, >> |
left |
| 6 | & |
left |
| 7 | ^ |
left |
| 8 | | |
left |
| 9 | ==, !=, <, >, <=, >= |
left |
| 10 | and |
left |
| 11 | or |
left |
Button Reads
Read controller input as boolean expressions:
if button.right {
player_x += SPEED
}
if button.a {
jump()
}
Available buttons: up, down, left, right, a, b, start, select.
For two-player games, prefix with the player:
if p1.button.a { /* player 1 */ }
if p2.button.right { /* player 2 */ }
Without a prefix, button refers to player 1.
Function Calls in Expressions
var clamped: u8 = clamp_x(player_x + SPEED)
Array Indexing
var val: u8 = table[i]
table[i] = 0
Type Casting
var wide: u16 = narrow as u16
Statements
Assignment
x = 10
x += 5
x -= 1
x &= 0x0F
x |= 0x80
x ^= mask
All assignment operators:
| Operator | Description |
|---|---|
= |
Assign |
+= |
Add and assign |
-= |
Subtract and assign |
&= |
AND and assign |
|= |
OR and assign |
^= |
XOR and assign |
Array element assignment:
enemies[i] = 0
scores[player] += 10
If / Else If / Else
Braces are always required. No ternary operator.
if health == 0 {
transition GameOver
} else if health < 3 {
flash_warning()
} else {
// normal gameplay
}
While Loop
var i: u8 = 0
while i < 10 {
enemies[i] = 0
i += 1
}
Match Statement
match matches a scrutinee against a sequence of patterns and
executes the body of the first matching arm. Each arm's pattern is
compared against the scrutinee with ==. An underscore arm _ acts
as the catch-all:
enum State { Title, Playing, GameOver }
var state: u8 = Title
on frame {
match state {
Title => {
if button.start { state = Playing }
}
Playing => {
// ... game logic ...
}
GameOver => {
if button.a { state = Title }
}
_ => {}
}
}
match desugars to an if / else if chain at parse time, so
patterns can be any expression that produces a value comparable to
the scrutinee.
For Loop
The for loop iterates over a half-open integer range [start, end):
for i in 0..8 {
total += arr[i]
}
The loop variable is a u8 scoped to the loop body. Both bounds can
be any expression that evaluates to u8 at runtime, including
constants or variables. The range is half-open, so 0..8 iterates
0, 1, 2, ..., 7 (8 iterations). For a closed range, use 0..9.
The loop is desugared into a while loop with an index variable, so
break and continue work the same as in any loop body.
Loop (Infinite)
loop {
wait_frame()
if button.start {
break
}
}
The compiler warns if a loop contains neither break, wait_frame, nor transition.
Break and Continue
var i: u8 = 0
while i < 20 {
i += 1
if enemies[i] == 0 {
continue // skip inactive enemies
}
if i > 10 {
break // stop processing
}
update_enemy(i)
}
Return
fun abs_diff(a: u8, b: u8) -> u8 {
if a > b {
return a - b
}
return b - a
}
Functions without a return type use return with no value (or simply reach the end of the function body).
Draw
Render a sprite to the screen:
draw Player at: (player_x, player_y)
draw Coin at: (COIN_X, COIN_Y) frame: anim_frame
The draw statement writes to the OAM shadow buffer. The NES supports up to 64 sprites per frame.
Syntax: draw SpriteName at: (x_expr, y_expr) [frame: expr]
Transition
Switch to another state immediately:
transition GameOver
The current state's on exit runs, then the target state's on enter runs.
Wait Frame
Yield execution until the next vertical blank (NMI). Synchronizes to the 60 Hz display refresh.
wait_frame()
This triggers OAM DMA transfer and PPU updates before yielding. Inside on frame, a wait_frame() is implicit at the end of each frame.
Scroll
Set the PPU scroll position:
scroll(scroll_x, scroll_y)
Load Background
Load a background nametable:
load_background TitleBG
Set Palette
Apply a palette:
set_palette GamePalette
Function Calls as Statements
reset_score()
update_physics(player_x, player_y)
Assets
Sprite Declarations
sprite Player {
chr: @chr("assets/player.png")
}
sprite Coin {
chr: @binary("assets/coin.bin")
}
Palette Declarations
Define color palettes using NES PPU color indices ($00-$3F):
palette GamePalette {
colors: [0x0F, 0x00, 0x10, 0x30,
0x0F, 0x07, 0x17, 0x27,
0x0F, 0x09, 0x19, 0x29,
0x0F, 0x01, 0x11, 0x21]
}
Each group of 4 values is a sub-palette. The first color is typically 0x0F (black) as the shared background color.
Background Declarations
background TitleBG {
chr: @chr("assets/title_screen.png")
}
Asset Sources
Three ways to provide asset data:
| Source | Description |
|---|---|
@chr("file.png") |
Convert PNG to CHR tile data |
@binary("file.bin") |
Include raw binary data verbatim |
Inline [0x00, 0x7E, ...] |
Hex byte array directly in source |
Mappers
The mapper determines cartridge hardware and available ROM size.
| Mapper | PRG ROM | CHR ROM | Features |
|---|---|---|---|
NROM |
16 or 32 KB | 8 KB | No banking, simplest |
MMC1 |
Up to 256 KB | Up to 128 KB | Switchable banks |
UxROM |
Up to 256 KB | 8 KB CHR RAM | PRG banking only |
MMC3 |
Up to 512 KB | Up to 256 KB | Scanline counter, banking |
Bank Declarations
For mappers with bank switching:
bank MainCode {
// Always-resident code (NMI handler, core engine)
}
bank Level1 {
state Level1 { ... }
background Level1BG { ... }
}
Banks can hold prg (code/data) or chr (graphics) content. Transitions between states in different banks automatically emit bank-switch and trampoline code.
Comments
// Line comment -- extends to end of line
/* Block comment
spans multiple lines */
Includes
Split your game across multiple files:
include "physics.ne"
include "enemies.ne"
Includes are resolved relative to the including file. Circular includes are a compile error. Duplicate includes are skipped automatically.
Debug Mode
Compile with --debug to enable runtime instrumentation. All debug features are stripped completely in release builds (zero bytes, zero cycles).
Debug Logging
debug.log("Player position: ", px, ", ", py)
Debug Assertions
debug.assert(lives > 0, "Lives should never be negative")
Runtime Checks (Debug Only)
In debug mode, the compiler inserts:
- Array bounds checking on indexed access
- Arithmetic overflow warnings
- Stack depth monitoring at function entry
- Frame overrun detection (warns if frame handler exceeds vblank period)
Hardware Intrinsics
For the common case of reading or writing a single PPU/APU/mapper register, NEScript provides two built-in intrinsics:
poke(0x2006, 0x3F) // write $3F to PPU address register
poke(0x2006, 0x00) // (second half of the address)
poke(0x2007, 0x0F) // write a palette byte to PPU data
var status: u8 = peek(0x2002) // read PPU status register
The address argument to both is a compile-time constant. Zero-page
addresses compile to STA $XX / LDA $XX; anything larger compiles
to absolute addressing.
Inline Assembly
For more elaborate sequences, use asm { ... } blocks:
fun fast_shift(input: u8) -> u8 {
var result: u8 = 0
asm {
LDA {input}
ASL A
ASL A
STA {result}
}
return result
}
Inside an asm block, {name} is replaced with the resolved
zero-page or absolute address of the variable name. Labels
defined with name: are local to the block.
Raw Assembly
raw asm {
LDA #$42
STA $2007
}
raw asm skips variable substitution — {name} is passed through
verbatim. Useful for completely unmanaged snippets that don't
reference NEScript variables.
Error Codes
Lexer Errors (E01xx)
| Code | Description |
|---|---|
| E0101 | Unterminated string literal |
| E0102 | Invalid character |
| E0103 | Number literal overflow |
Type Errors (E02xx)
| Code | Description |
|---|---|
| E0201 | Type mismatch |
| E0202 | Invalid cast |
| E0203 | Invalid operation for type |
Memory Errors (E03xx)
| Code | Description |
|---|---|
| E0301 | Zero-page overflow |
Control Flow Errors (E04xx)
| Code | Description |
|---|---|
| E0401 | Call depth exceeded |
| E0402 | Recursion detected |
| E0403 | Unreachable state |
| E0404 | Transition to undefined state |
Declaration Errors (E05xx)
| Code | Description |
|---|---|
| E0501 | Duplicate declaration |
| E0502 | Undefined variable |
| E0503 | Undefined function |
| E0504 | Missing start declaration |
| E0505 | Multiple start declarations |
Warnings (W01xx)
| Code | Description |
|---|---|
| W0101 | Expensive multiply/divide operation |
| W0102 | Loop without break or wait_frame |
| W0103 | Unused variable |
| W0104 | Unreachable code |
Example Error Output
error[E0201]: type mismatch
--> game.ne:42:15
|
42 | var x: u8 = -5
| ^^ expected u8, found negative integer
|
= help: use i8 if you need negative values: var x: i8 = -5
error[E0402]: recursion is not allowed
--> game.ne:55:5
|
55 | flood_fill(x + 1, y)
| ^^^^^^^^^^^^^^^^^^^^
|
= note: flood_fill calls itself (directly recursive)
= help: the NES has only 256 bytes of stack; use an iterative algorithm instead
Command Line
Compile a .ne source file into a .nes ROM:
nescript build game.ne
nescript build game.ne --output my_game.nes
nescript build game.ne --debug
nescript build game.ne --asm-dump
nescript build game.ne --dump-ir
| Flag | Description |
|---|---|
--output |
Set output ROM file path (default: input.nes) |
--debug |
Enable debug mode with runtime checks |
--asm-dump |
Dump generated 6502 assembly to stdout |
--dump-ir |
Dump the lowered IR program (after optimization) to stdout |
--memory-map |
Dump a memory map of variable allocations to stdout |
--call-graph |
Dump a call graph (which handler/function calls which) to stdout |
Check
Type-check a source file without producing a ROM:
nescript check game.ne
Complete Example
A full game demonstrating states, input, functions, constants, and transitions:
game "Coin Cavern" {
mapper: NROM
}
const SPEED: u8 = 2
const SCREEN_RIGHT: u8 = 240
const COIN_X: u8 = 180
const COIN_Y: u8 = 100
var player_x: u8 = 40
var player_y: u8 = 200
var score: u8 = 0
var coins_left: u8 = 3
fun clamp_x(val: u8) -> u8 {
if val > SCREEN_RIGHT {
return 0
}
return val
}
state Title {
on frame {
draw Logo at: (100, 100)
if button.start {
transition Playing
}
}
}
state Playing {
on enter {
player_x = 40
player_y = 200
score = 0
coins_left = 3
}
on frame {
if button.right {
player_x += SPEED
if player_x > SCREEN_RIGHT {
player_x = SCREEN_RIGHT
}
}
if button.left {
if player_x >= SPEED {
player_x -= SPEED
} else {
player_x = 0
}
}
if player_x >= COIN_X {
if player_y >= COIN_Y {
score += 1
coins_left -= 1
if coins_left == 0 {
transition GameOver
}
}
}
draw Player at: (player_x, player_y)
draw Coin at: (COIN_X, COIN_Y)
}
}
state GameOver {
on frame {
draw Trophy at: (120, 100)
if button.start {
transition Title
}
}
}
start Title
Build and run:
nescript build coin_cavern.ne
# produces coin_cavern.nes -- open in any NES emulator