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
6.4 KiB
Compiler Architecture
An overview of the NEScript compiler internals for contributors and maintainers.
Pipeline
Source (.ne) --> Lexer --> Parser --> Analyzer --> IR Lowering --> Optimizer --> Codegen --> Assembler --> Linker --> ROM (.nes)
Each phase is a pure function (input to output) with no global state, making every stage independently testable.
.ne source ---------> Lexer -----> Parser -----> Analyzer ------>
(tokens) (AST) (annotated AST)
IR Lowering -----> Optimizer -----> Codegen -----> Linker -----> ROM
(IR) (optimized IR) (6502 insns) (.nes file)
Phase Summary
| Phase | Input | Output | Responsibility |
|---|---|---|---|
| Lexer | Source text | Token stream | Tokenization, number/string literal parsing |
| Parser | Token stream | AST | Syntax validation, tree construction |
| Analyzer | AST | Annotated AST | Type checking, scope resolution, call graph analysis |
| IR Lowering | Annotated AST | NEScript IR | Flatten expressions, expand u16 ops, desugar |
| Optimizer | IR | Optimized IR | Constant folding, dead code, ZP promotion, inlining |
| Codegen | Optimized IR | 6502 instruction list | Register allocation, instruction selection |
| Assembler | 6502 instructions | Byte sequences + fixups | Opcode encoding, address resolution |
| Linker | Bytes + assets | .nes file | Bank layout, vectors, iNES header |
Modules
lexer/
Tokenizes NEScript source text into a stream of typed tokens with source spans. Handles decimal, hex, and binary integer literals, string literals, all keywords, and operators.
parser/
Recursive descent parser that converts the token stream into an AST. Defines all AST node types (expressions, statements, declarations) in ast.rs.
analyzer/
Performs semantic analysis on the AST: type checking (types.rs), scope and symbol table management (scope.rs), and call graph construction with depth analysis (call_graph.rs). Detects recursion, type mismatches, undefined references, and call depth violations.
ir/
Defines the intermediate representation and the lowering pass (lowering.rs) that translates the annotated AST into IR. Flattens nested expressions, expands 16-bit operations into 8-bit sequences, and resolves syntactic sugar.
optimizer/
Runs optimization passes over the IR: constant folding (const_fold.rs), dead code elimination (dead_code.rs), zero-page promotion analysis (zp_promote.rs), and function inlining (inliner.rs).
codegen/
Translates optimized IR into 6502 instructions. Includes register allocation for the A/X/Y registers (regalloc.rs) and instruction pattern matching for idiomatic 6502 code (patterns.rs).
asm/
The built-in assembler. Encodes 6502 instructions (encode.rs) with all addressing modes (addressing.rs), using a complete opcode table (opcodes.rs -- 56 instructions across all modes).
linker/
Assigns addresses to code and data segments, resolves fixups/relocations (fixups.rs), and handles bank allocation (banks.rs) for banked mappers.
rom/
Builds the final iNES ROM file. Generates the 16-byte iNES header (header.rs) and places the NMI/RESET/IRQ vector table (vectors.rs).
runtime/
Contains built-in runtime code that the compiler emits into every ROM: NES hardware initialization, NMI handler generation, controller read routines, OAM DMA setup, software multiply/divide, and the frame-walking audio driver (gen_audio_tick, gen_period_table, gen_data_block).
assets/
The asset pipeline. Converts PNG images to CHR tile data (chr.rs), maps RGB colors to the NES palette (palette.rs), resolves sprite/background declarations into tile-indexed CHR blocks (resolve.rs), and compiles sfx/music declarations into ROM-ready envelope and note-stream byte tables (audio.rs) — plus the builtin effect/track tables used as fallbacks when programs reference audio names without declaring them.
debug/
Debug instrumentation output. Generates source maps relating ROM addresses to source locations (source_map.rs), symbol tables compatible with Mesen (symbols.rs), and runtime check code for debug builds (checks.rs).
errors/
Error reporting infrastructure. Defines the Diagnostic struct with error codes, severity levels, source spans, labels, help text, and notes (diagnostic.rs). Renders diagnostics with color and source context for terminal output (render.rs).
Testing
Test Organization
Tests are co-located with each module in tests.rs files:
src/lexer/tests.rs -- lexer unit tests
src/parser/tests.rs -- parser unit tests
src/analyzer/tests.rs -- semantic analysis tests
src/ir/tests.rs -- IR lowering tests
src/optimizer/tests.rs -- optimizer tests
src/codegen/tests.rs -- code generation tests
src/asm/tests.rs -- assembler tests
src/linker/tests.rs -- linker tests
src/rom/tests.rs -- ROM builder tests
src/assets/tests.rs -- asset pipeline tests
Integration tests live in the tests/ directory:
tests/integration/ -- full pipeline tests with .ne source files
tests/error_tests/ -- tests that verify specific error codes
tests/asm_tests/ -- 6502 opcode and addressing mode tests
Running Tests
# Run all tests
cargo test
# Run tests for a specific module
cargo test --lib lexer
cargo test --lib parser
cargo test --lib analyzer
# Run integration tests only
cargo test --test '*'
# Run a specific test by name
cargo test test_name
Test Strategy
Each compiler phase is designed as a pure function, so unit tests provide isolated input and verify output without side effects. Integration tests compile complete .ne source files and verify the output ROM matches expected golden files in tests/integration/expected/.
Error tests in tests/error_tests/ contain intentionally broken programs and verify that the correct error code is produced (e.g., recursion.ne should produce E0402).