mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
1150 lines
28 KiB
Markdown
1150 lines
28 KiB
Markdown
# 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 |
|
|
|
|
---
|
|
|
|
## Audio
|
|
|
|
NEScript ships with a full data-driven audio subsystem. Sound effects run on pulse channel 1 and music runs on pulse channel 2, both driven by an NMI-time tick that walks per-track data tables compiled into PRG ROM. Programs that never touch audio pay zero ROM or cycle cost — the driver and its period table are only linked in when user code contains at least one `play`, `start_music`, or `stop_music` statement.
|
|
|
|
### Statements
|
|
|
|
```
|
|
play SfxName // trigger a one-shot sound effect
|
|
start_music TrackName // begin looping background music
|
|
stop_music // silence the music channel
|
|
```
|
|
|
|
Each statement looks up the name in the program's user declarations first, then falls back to the builtin table. Unknown names are a hard error (E0505).
|
|
|
|
### SFX Declarations
|
|
|
|
An `sfx` block is a frame-accurate envelope for pulse 1. `pitch` latches the pulse period on trigger; `volume` runs one entry per frame, so the envelope length controls the effect duration.
|
|
|
|
```
|
|
sfx Pickup {
|
|
duty: 2 // 0-3, 2 = 50% square (default)
|
|
pitch: [0x50, 0x50, 0x50, 0x50, 0x50] // period for each frame
|
|
volume: [15, 12, 9, 6, 3] // 0-15, one per frame
|
|
}
|
|
```
|
|
|
|
Rules:
|
|
- `pitch` and `volume` must have the same length (the frame count).
|
|
- `volume` values are 0-15 (4-bit pulse volume).
|
|
- `duty` is 0-3 and defaults to 2.
|
|
- Maximum 120 frames (2 seconds at 60 fps).
|
|
|
|
### Music Declarations
|
|
|
|
A `music` block is a flat list of `(pitch, duration)` note pairs played on pulse 2. Pitch 0 is a rest; pitches 1-60 are indices into the builtin 60-note period table (C1 through B5, with middle C at index 37). Duration is in frames (so at 60 fps, `30` is half a second).
|
|
|
|
```
|
|
music Theme {
|
|
duty: 2 // 0-3 (default 2)
|
|
volume: 10 // 0-15 (default 10)
|
|
repeat: true // loop when track ends (default true)
|
|
notes: [
|
|
37, 20, // C4 for 20 frames
|
|
41, 20, // E4
|
|
44, 20, // G4
|
|
49, 20, // C5
|
|
0, 10, // rest for 10 frames
|
|
]
|
|
}
|
|
```
|
|
|
|
Rules:
|
|
- `notes` must contain an even number of bytes (pitch + duration pairs).
|
|
- Pitches are 0 (rest) or 1-60 (period table index).
|
|
- Duration must be ≥ 1 frame.
|
|
- Maximum 256 notes per track.
|
|
|
|
### Builtin Names
|
|
|
|
For programs that want classic game audio without writing data tables, NEScript provides a handful of builtin effects and tracks that can be used directly:
|
|
|
|
**Builtin SFX**
|
|
|
|
| Name | Description |
|
|
|------|-------------|
|
|
| `coin`, `pickup`, `collect` | Ascending high blip |
|
|
| `jump`, `hop` | Descending arc |
|
|
| `hit`, `damage`, `explode` | Low blast |
|
|
| `click`, `select`, `confirm` | Sharp beep |
|
|
| `cancel`, `back`, `error` | Low longer tone |
|
|
| `shoot`, `laser`, `fire` | Very high pulse |
|
|
| `step`, `footstep` | Short low thud |
|
|
|
|
**Builtin Music**
|
|
|
|
| Name | Description |
|
|
|------|-------------|
|
|
| `title`, `theme`, `main` | Major arpeggio (looping) |
|
|
| `battle`, `boss` | Driving pulse (looping) |
|
|
| `win`, `victory`, `fanfare` | Ascending burst (one-shot) |
|
|
| `gameover`, `lose`, `fail` | Descending dirge (looping) |
|
|
|
|
A user-declared `sfx` or `music` block takes priority over a builtin with the same name, so `sfx coin { ... }` will shadow the default coin effect.
|
|
|
|
### How It Works
|
|
|
|
Compile time:
|
|
|
|
1. The resolver compiles each `sfx` into `(period_lo, period_hi, envelope[])` and each `music` into `(header, (pitch, duration)[])`, appending builtins for any referenced name that isn't user-declared.
|
|
2. The IR codegen emits `play Name` as: write trigger bytes to `$4002`/`$4003`, load envelope pointer into `$0C/$0D`, set the sfx counter. `start_music Name` stamps a state byte into `$07`, loads the stream pointer into `$0E/$0F` (and the loop base into `$05/$06`), and primes the duration counter.
|
|
3. The linker splices the audio tick, the 60-entry period table, and every compiled sfx/music blob into PRG ROM, all guarded on a `__audio_used` marker label so silent programs never pay the cost.
|
|
|
|
Runtime (every NMI, if audio is in use):
|
|
|
|
1. **SFX**: if the counter is nonzero, read one envelope byte through `(ZP_SFX_PTR),Y` and write it to `$4000`. A zero sentinel mutes pulse 1 and stops the tick.
|
|
2. **Music**: if active and the note counter hits zero, read the next pitch byte. 0 = rest (mute pulse 2). 1-60 = look up the period in the table and write to `$4006`/`$4007`. `0xFF` = loop back to the base pointer (or mute if `repeat: false`). Then read the duration byte and reload the counter.
|
|
|
|
Total memory cost: 8 bytes of zero page, ~200 bytes for the driver body, 120 bytes for the period table, plus the data for each user-declared sfx/music.
|
|
|
|
---
|
|
|
|
## 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
|
|
```
|