Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
use clap::Parser;
|
2026-04-14 03:01:32 +00:00
|
|
|
use std::io::Write as _;
|
2026-04-12 10:01:44 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
|
|
|
|
|
use nescript::analyzer;
|
2026-04-14 03:01:32 +00:00
|
|
|
use nescript::assets::{BackgroundData, PaletteData};
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
use nescript::errors::render_diagnostics;
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
use nescript::linker::{render_mlb, render_source_map, LinkedRom};
|
|
|
|
|
use nescript::pipeline::{compile_source, CompileError, CompileOptions as PipelineOptions};
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
|
|
|
|
|
#[derive(Parser)]
|
|
|
|
|
#[command(name = "nescript", about = "NEScript compiler — NES game development")]
|
|
|
|
|
enum Cli {
|
|
|
|
|
/// Compile a .ne source file into a .nes ROM
|
|
|
|
|
Build {
|
|
|
|
|
/// Input source file
|
|
|
|
|
input: PathBuf,
|
|
|
|
|
|
|
|
|
|
/// Output ROM file (default: input with .nes extension)
|
|
|
|
|
#[arg(short, long)]
|
|
|
|
|
output: Option<PathBuf>,
|
2026-04-12 00:09:47 +00:00
|
|
|
|
|
|
|
|
/// Enable debug mode (runtime checks, debug.log)
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
debug: bool,
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
|
|
|
|
|
/// Dump generated 6502 assembly to stdout
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
asm_dump: bool,
|
2026-04-12 10:23:43 +00:00
|
|
|
|
2026-04-12 11:31:01 +00:00
|
|
|
/// Dump the lowered IR program to stdout (after optimization)
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
dump_ir: bool,
|
|
|
|
|
|
2026-04-12 17:43:39 +00:00
|
|
|
/// Dump a human-readable memory map of variable allocations
|
|
|
|
|
/// to stdout.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
memory_map: bool,
|
|
|
|
|
|
2026-04-12 17:46:13 +00:00
|
|
|
/// Dump a call graph showing which functions call which.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
call_graph: bool,
|
2026-04-14 01:43:51 +00:00
|
|
|
|
|
|
|
|
/// Skip the IR optimizer pass. Useful for bisecting
|
|
|
|
|
/// optimizer-introduced miscompiles: if a program misbehaves
|
|
|
|
|
/// with the optimizer on but works with `--no-opt`, the bug
|
|
|
|
|
/// lives in `src/optimizer/`.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
no_opt: bool,
|
2026-04-14 02:39:36 +00:00
|
|
|
|
|
|
|
|
/// Write a Mesen-compatible symbol file (`.mlb`) next to the
|
|
|
|
|
/// ROM. Contains one `<type>:<address>:<label>` entry per
|
|
|
|
|
/// function, state handler, and user variable. Enables
|
|
|
|
|
/// symbol-level debugging in Mesen / fceux without manual
|
|
|
|
|
/// address lookups.
|
|
|
|
|
#[arg(long, value_name = "PATH")]
|
|
|
|
|
symbols: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
/// Write a plain-text source map (`.map`) next to the ROM.
|
|
|
|
|
/// Each line has the form `<rom_offset_hex> <file_id>
|
|
|
|
|
/// <line> <col>` and records the position of every IR-level
|
|
|
|
|
/// statement in the assembled fixed bank. Useful for
|
|
|
|
|
/// reverse-mapping a crash address back to the source.
|
|
|
|
|
#[arg(long, value_name = "PATH")]
|
|
|
|
|
source_map: Option<PathBuf>,
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
},
|
|
|
|
|
/// Type-check a source file without building
|
|
|
|
|
Check {
|
|
|
|
|
/// Input source file
|
|
|
|
|
input: PathBuf,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn main() {
|
|
|
|
|
let cli = Cli::parse();
|
|
|
|
|
|
|
|
|
|
match cli {
|
2026-04-12 00:09:47 +00:00
|
|
|
Cli::Build {
|
|
|
|
|
input,
|
|
|
|
|
output,
|
|
|
|
|
debug,
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
asm_dump,
|
2026-04-12 11:31:01 +00:00
|
|
|
dump_ir,
|
2026-04-12 17:43:39 +00:00
|
|
|
memory_map,
|
2026-04-12 17:46:13 +00:00
|
|
|
call_graph,
|
2026-04-14 01:43:51 +00:00
|
|
|
no_opt,
|
2026-04-14 02:39:36 +00:00
|
|
|
symbols,
|
|
|
|
|
source_map,
|
2026-04-12 00:09:47 +00:00
|
|
|
} => {
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
let output = output.unwrap_or_else(|| input.with_extension("nes"));
|
2026-04-12 11:31:01 +00:00
|
|
|
match compile(
|
|
|
|
|
&input,
|
|
|
|
|
&CompileOptions {
|
|
|
|
|
debug,
|
|
|
|
|
asm_dump,
|
|
|
|
|
dump_ir,
|
2026-04-12 17:43:39 +00:00
|
|
|
memory_map,
|
2026-04-12 17:46:13 +00:00
|
|
|
call_graph,
|
2026-04-14 01:43:51 +00:00
|
|
|
no_opt,
|
2026-04-14 02:39:36 +00:00
|
|
|
symbols: symbols.clone(),
|
|
|
|
|
source_map: source_map.clone(),
|
2026-04-12 11:31:01 +00:00
|
|
|
},
|
|
|
|
|
) {
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
Ok(rom) => {
|
|
|
|
|
std::fs::write(&output, rom).unwrap_or_else(|e| {
|
|
|
|
|
eprintln!("error: failed to write {}: {e}", output.display());
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
});
|
|
|
|
|
println!(
|
|
|
|
|
"compiled {} -> {} ({} bytes)",
|
|
|
|
|
input.display(),
|
|
|
|
|
output.display(),
|
|
|
|
|
std::fs::metadata(&output).map(|m| m.len()).unwrap_or(0)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Err(()) => std::process::exit(1),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Cli::Check { input } => match check(&input) {
|
|
|
|
|
Ok(()) => println!("no errors found in {}", input.display()),
|
|
|
|
|
Err(()) => std::process::exit(1),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:01:32 +00:00
|
|
|
/// Write a human-readable memory map of variable allocations to
|
|
|
|
|
/// `w`. Entries are sorted by address and labelled with their scope
|
|
|
|
|
/// (zero-page vs RAM). When `link_result` is `Some(_)`, a PRG ROM
|
|
|
|
|
/// section listing each palette and background data blob's CPU
|
|
|
|
|
/// address + size is appended — the CLI passes the linker result
|
|
|
|
|
/// whenever it's available, which is always unless the caller is
|
|
|
|
|
/// unit-testing the variable-only path.
|
|
|
|
|
///
|
|
|
|
|
/// This function is factored out of the direct `println!` path so
|
|
|
|
|
/// tests can drive it against an in-memory buffer and assert on the
|
|
|
|
|
/// rendered output.
|
|
|
|
|
fn write_memory_map(
|
|
|
|
|
w: &mut impl std::io::Write,
|
|
|
|
|
analysis: &nescript::analyzer::AnalysisResult,
|
|
|
|
|
link_result: Option<&LinkedRom>,
|
|
|
|
|
palettes: &[PaletteData],
|
|
|
|
|
backgrounds: &[BackgroundData],
|
|
|
|
|
) -> std::io::Result<()> {
|
2026-04-12 17:43:39 +00:00
|
|
|
let mut allocs: Vec<_> = analysis.var_allocations.iter().collect();
|
|
|
|
|
allocs.sort_by_key(|a| a.address);
|
|
|
|
|
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(w, "=== NEScript Memory Map ===")?;
|
|
|
|
|
writeln!(w, "Zero Page ($00-$FF):")?;
|
|
|
|
|
writeln!(
|
|
|
|
|
w,
|
|
|
|
|
" $00-$0F [SYSTEM] reserved (frame flag, input, state, params, scratch)"
|
|
|
|
|
)?;
|
2026-04-12 17:43:39 +00:00
|
|
|
for a in allocs.iter().filter(|a| a.address < 0x100) {
|
|
|
|
|
if a.size == 1 {
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?;
|
2026-04-12 17:43:39 +00:00
|
|
|
} else {
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(
|
|
|
|
|
w,
|
2026-04-12 17:43:39 +00:00
|
|
|
" ${:04X}-${:04X} [USER] {} ({} bytes)",
|
|
|
|
|
a.address,
|
|
|
|
|
a.address + a.size - 1,
|
|
|
|
|
a.name,
|
|
|
|
|
a.size
|
2026-04-14 03:01:32 +00:00
|
|
|
)?;
|
2026-04-12 17:43:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ram_allocs: Vec<_> = allocs.iter().filter(|a| a.address >= 0x100).collect();
|
|
|
|
|
if !ram_allocs.is_empty() {
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(w, "\nRAM ($0200-$07FF):")?;
|
|
|
|
|
writeln!(w, " $0200-$02FF [SYSTEM] OAM shadow buffer")?;
|
2026-04-12 17:43:39 +00:00
|
|
|
for a in &ram_allocs {
|
|
|
|
|
if a.size == 1 {
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?;
|
2026-04-12 17:43:39 +00:00
|
|
|
} else {
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(
|
|
|
|
|
w,
|
2026-04-12 17:43:39 +00:00
|
|
|
" ${:04X}-${:04X} [USER] {} ({} bytes)",
|
|
|
|
|
a.address,
|
|
|
|
|
a.address + a.size - 1,
|
|
|
|
|
a.name,
|
|
|
|
|
a.size
|
2026-04-14 03:01:32 +00:00
|
|
|
)?;
|
2026-04-12 17:43:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Summary line.
|
|
|
|
|
let zp_used: u16 = allocs
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|a| a.address < 0x80)
|
|
|
|
|
.map(|a| a.size)
|
|
|
|
|
.sum();
|
|
|
|
|
let ram_used: u16 = allocs
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|a| a.address >= 0x300)
|
|
|
|
|
.map(|a| a.size)
|
|
|
|
|
.sum();
|
2026-04-14 03:01:32 +00:00
|
|
|
writeln!(w)?;
|
|
|
|
|
writeln!(w, "Zero Page: {zp_used}/128 bytes used")?;
|
|
|
|
|
writeln!(w, "Main RAM: {ram_used}/1280 bytes used")?;
|
|
|
|
|
|
|
|
|
|
// PRG ROM: palette (32 B each) and background (960 + 64 B each)
|
|
|
|
|
// data blobs. The linker emits each one under a well-known
|
|
|
|
|
// label — `__palette_<name>`, `__bg_tiles_<name>`,
|
|
|
|
|
// `__bg_attrs_<name>` — so we look those up in the label table
|
|
|
|
|
// and render the CPU address + byte count.
|
|
|
|
|
if let Some(link) = link_result {
|
|
|
|
|
if !palettes.is_empty() || !backgrounds.is_empty() {
|
|
|
|
|
writeln!(w, "\nPRG ROM data blobs:")?;
|
|
|
|
|
let mut total: u32 = 0;
|
|
|
|
|
for pal in palettes {
|
|
|
|
|
let label = pal.label();
|
|
|
|
|
match link.labels.get(&label).copied() {
|
|
|
|
|
Some(addr) => {
|
|
|
|
|
writeln!(w, " ${addr:04X} [PALETTE] {} (32 bytes)", pal.name)?;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
writeln!(w, " (unlinked) [PALETTE] {} (32 bytes)", pal.name)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
total += 32;
|
|
|
|
|
}
|
|
|
|
|
for bg in backgrounds {
|
|
|
|
|
let tiles_label = bg.tiles_label();
|
|
|
|
|
let attrs_label = bg.attrs_label();
|
|
|
|
|
match link.labels.get(&tiles_label).copied() {
|
|
|
|
|
Some(addr) => {
|
|
|
|
|
writeln!(w, " ${addr:04X} [BG-TILES] {} (960 bytes)", bg.name)?;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
writeln!(w, " (unlinked) [BG-TILES] {} (960 bytes)", bg.name)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
match link.labels.get(&attrs_label).copied() {
|
|
|
|
|
Some(addr) => {
|
|
|
|
|
writeln!(w, " ${addr:04X} [BG-ATTRS] {} (64 bytes)", bg.name)?;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
writeln!(w, " (unlinked) [BG-ATTRS] {} (64 bytes)", bg.name)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
total += 960 + 64;
|
|
|
|
|
}
|
|
|
|
|
writeln!(w, "\nPRG ROM data total: {total} bytes")?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Print a human-readable memory map of variable allocations. Thin
|
|
|
|
|
/// wrapper around [`write_memory_map`] that drives stdout; tests
|
|
|
|
|
/// call `write_memory_map` directly against a `Vec<u8>`.
|
|
|
|
|
fn print_memory_map(
|
|
|
|
|
analysis: &nescript::analyzer::AnalysisResult,
|
|
|
|
|
link_result: Option<&LinkedRom>,
|
|
|
|
|
palettes: &[PaletteData],
|
|
|
|
|
backgrounds: &[BackgroundData],
|
|
|
|
|
) {
|
|
|
|
|
let stdout = std::io::stdout();
|
|
|
|
|
let mut handle = stdout.lock();
|
|
|
|
|
// Infallible: stdout writes only return Err on broken pipes,
|
|
|
|
|
// which is the caller's problem.
|
|
|
|
|
let _ = write_memory_map(&mut handle, analysis, link_result, palettes, backgrounds);
|
|
|
|
|
let _ = handle.flush();
|
2026-04-12 17:43:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:46:13 +00:00
|
|
|
/// Print a human-readable call graph of the analyzed program.
|
|
|
|
|
/// Entries show the max call depth reached from each entry point
|
|
|
|
|
/// (state handler) and the transitive callees.
|
|
|
|
|
fn print_call_graph(analysis: &nescript::analyzer::AnalysisResult) {
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
let sorted: BTreeMap<_, _> = analysis
|
|
|
|
|
.call_graph
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
let max_depth = analysis.max_depths.values().copied().max().unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
println!("=== Call Graph (max depth: {max_depth} / 8) ===");
|
|
|
|
|
if sorted.is_empty() {
|
|
|
|
|
println!(" (no functions or handlers)");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (caller, callees) in &sorted {
|
|
|
|
|
if let Some(depth) = analysis.max_depths.get(caller) {
|
|
|
|
|
println!("{caller} (max depth {depth})");
|
|
|
|
|
} else {
|
|
|
|
|
println!("{caller}");
|
|
|
|
|
}
|
|
|
|
|
if callees.is_empty() {
|
|
|
|
|
println!(" └── (leaf)");
|
|
|
|
|
} else {
|
|
|
|
|
let count = callees.len();
|
|
|
|
|
for (i, callee) in callees.iter().enumerate() {
|
|
|
|
|
let branch = if i + 1 == count {
|
|
|
|
|
"└──"
|
|
|
|
|
} else {
|
|
|
|
|
"├──"
|
|
|
|
|
};
|
|
|
|
|
println!(" {branch} {callee}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
fn dump_asm(instructions: &[nescript::asm::Instruction]) {
|
2026-04-12 16:29:15 +00:00
|
|
|
use nescript::asm::{AddressingMode, Opcode};
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
for inst in instructions {
|
2026-04-12 16:29:15 +00:00
|
|
|
// A bare `NOP` with a `Label` operand is a label *definition*
|
|
|
|
|
// (the pseudo-instruction the codegen emits when marking a
|
|
|
|
|
// position). Any other opcode with `Label` mode is an actual
|
|
|
|
|
// instruction like `JSR foo` or `JMP bar`, so we show the
|
|
|
|
|
// opcode + target.
|
|
|
|
|
if inst.opcode == Opcode::NOP {
|
|
|
|
|
if let AddressingMode::Label(name) = &inst.mode {
|
|
|
|
|
println!("{name}:");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
}
|
|
|
|
|
println!(" {:?} {:?}", inst.opcode, inst.mode);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:31:01 +00:00
|
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
|
|
|
struct CompileOptions {
|
|
|
|
|
debug: bool,
|
|
|
|
|
asm_dump: bool,
|
|
|
|
|
dump_ir: bool,
|
2026-04-12 17:43:39 +00:00
|
|
|
memory_map: bool,
|
2026-04-12 17:46:13 +00:00
|
|
|
call_graph: bool,
|
2026-04-14 01:43:51 +00:00
|
|
|
no_opt: bool,
|
2026-04-14 02:39:36 +00:00
|
|
|
symbols: Option<PathBuf>,
|
|
|
|
|
source_map: Option<PathBuf>,
|
2026-04-12 11:31:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
// File I/O + preprocessing lives here so the pipeline module
|
|
|
|
|
// itself doesn't need to touch `std::fs`. That keeps the
|
|
|
|
|
// pipeline usable from a future WASM host that routes asset
|
|
|
|
|
// reads through a trait.
|
2026-04-12 10:06:58 +00:00
|
|
|
let raw_source = std::fs::read_to_string(input).map_err(|e| {
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
eprintln!("error: failed to read {}: {e}", input.display());
|
|
|
|
|
})?;
|
2026-04-12 10:06:58 +00:00
|
|
|
let source = nescript::parser::preprocess_source(&raw_source, Some(input)).map_err(|e| {
|
|
|
|
|
eprintln!("error: {e}");
|
|
|
|
|
})?;
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
let filename = input.to_string_lossy();
|
2026-04-12 10:01:44 +00:00
|
|
|
let source_dir = input.parent().unwrap_or_else(|| Path::new("."));
|
|
|
|
|
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
// Hand everything else off to the shared pipeline function
|
|
|
|
|
// so the CLI, the `compile` bench, and the integration-test
|
|
|
|
|
// helper all run the same compile path. When this block
|
|
|
|
|
// needs a new feature (new flag, new output, whatever), the
|
|
|
|
|
// change lands in `pipeline::compile_source` and every
|
|
|
|
|
// caller picks it up automatically.
|
|
|
|
|
let pipeline_opts = PipelineOptions {
|
|
|
|
|
debug: opts.debug,
|
|
|
|
|
no_opt: opts.no_opt,
|
|
|
|
|
emit_source_map: opts.source_map.is_some(),
|
|
|
|
|
};
|
|
|
|
|
let out = compile_source(&source, source_dir, &pipeline_opts).map_err(|e| match e {
|
|
|
|
|
CompileError::Parse(diags) => {
|
|
|
|
|
render_diagnostics(&source, &filename, &diags);
|
|
|
|
|
}
|
|
|
|
|
CompileError::ParseProducedNothing => {
|
|
|
|
|
// The parser returned `None` with no diagnostics.
|
|
|
|
|
// Extremely unusual (empty input or similar) and
|
|
|
|
|
// there's nothing for the user to act on beyond a
|
|
|
|
|
// generic message.
|
|
|
|
|
eprintln!("error: parser produced no program");
|
|
|
|
|
}
|
|
|
|
|
CompileError::Analyze(diags) => {
|
|
|
|
|
render_diagnostics(&source, &filename, &diags);
|
|
|
|
|
}
|
|
|
|
|
CompileError::AssetResolution(msg) => {
|
|
|
|
|
eprintln!("error: {msg}");
|
|
|
|
|
}
|
2026-04-14 03:01:32 +00:00
|
|
|
})?;
|
palette/background: first-class declarations with reset-time load and runtime swaps
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.
Pipeline:
- Lexer: re-adds `palette`, `background`, `set_palette`,
`load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
(name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
`Program`. `Statement::SetPalette` and `Statement::LoadBackground`
name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
{ tiles: [...], attributes: [...] }` blocks and their statement
forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
(<=32), nametable length (<=960), attribute length (<=64), and
duplicate decl names. `set_palette` / `load_background` targets
must reference a declared name (E0502 otherwise). When a program
declares palette or background, the analyzer bumps the user
zero-page allocator's starting address from `$10` to `$18` to
reserve `$11-$17` for the runtime update handshake — programs
that don't use the feature keep the old layout so their emulator
goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
expose `label()` / `tiles_label()` / `attrs_label()` for codegen
to reference.
- IR: new `IrOp::SetPalette(String)` and
`IrOp::LoadBackground(String)`; lowering forwards the names
verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
`gen_load_background` does the same for tile/attribute pointers
at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
marker so the linker splices in the NMI apply helper only when
the feature is actually used.
- Runtime: `gen_initial_palette_load` and
`gen_initial_background_load` write the first declared
palette/background at reset time (before rendering is enabled,
where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
new flag; when true it splices `gen_ppu_update_apply` at the top
of the NMI body, which checks the `$11` flags byte and copies
pending palette / nametable data to `$3F00` / `$2000` inside
vblank. All helpers use only ZP $02/$03 as scratch at reset time
and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
`BackgroundData`; splices each blob as a labelled data block in
PRG ROM, picks the first-declared as the reset-time load target,
enables background rendering automatically when a background is
declared, and threads `has_ppu_updates` into `gen_nmi`. Old
`link_banked` remains as a thin wrapper for callers without
palette/background data so existing tests don't shift.
Tests:
- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
without attributes, plus `set_palette` / `load_background`
statements.
- Analyzer: 9 new tests covering acceptance of declared
palettes/backgrounds, E0502 for unknown names, E0201 for
out-of-range NES colors and oversized blobs, E0501 for duplicate
names, and the zero-page-layout guard (palette/bg decls bump ZP
start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
PRG, `STA $12` / `STA $14` emitted by set_palette /
load_background codegen, and a regression guard that programs
without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
frame counter that toggles between `CoolBlues` / `WarmReds` and
`TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
hash checked in under `tests/emulator/goldens/` and verified via
`node run_examples.mjs` — rendered image shows the blue
`CoolBlues` palette with the nametable populated from
`TitleScreen`.
Docs:
- `README.md` adds the feature to the headline list and the example
table.
- `docs/language-guide.md` restores the palette/background sections
with the full 32-byte layout table and `set_palette` /
`load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
with the remaining gaps (PNG-sourced palette and nametable
assets, cross-vblank large background updates, memory-map
reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.
All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 11:11:33 +00:00
|
|
|
|
sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry
W0109 (shipped last commit) catches the 8-sprites-per-scanline
hardware limit at compile time for static layouts, but the
dynamic case — enemy formations, projectile clusters, animated
NPCs where coordinates come from variables — was still silent.
This change adds two layers of defense on top of W0109:
Layer 2: `cycle_sprites` runtime flicker intrinsic
New keyword statement that rotates the OAM DMA start offset
one slot per call. When called once per `on frame`, the PPU's
sprite evaluation picks up a different subset of the 12+
overlapping sprites each frame, so the permanent-dropout
failure mode becomes visible flicker — the classic NES
technique used by Gradius, Battletoads, and every shmup.
Implementation:
- Lexer keyword `KwCycleSprites` and parser production.
- AST `Statement::CycleSprites(Span)`.
- `IrOp::CycleSprites` lowered by the IR pass.
- Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with
natural u8 wrap, plus a one-shot `__sprite_cycle_used`
marker label the first time it fires.
- Linker detects the marker and switches `gen_nmi` to the
cycling variant, which reads the rotating offset from
`$07EF` into OAM_ADDR before the DMA instead of writing
a literal 0. Programs that don't call `cycle_sprites`
skip the marker and get byte-identical ROM output.
Layer 3: debug-mode sprite overflow telemetry
Mirrors the frame-overrun pair (`debug.frame_overrun_count` /
`debug.frame_overran`). In debug builds the NMI handler reads
`$2002` at the top of vblank, masks bit 5 (the PPU's sprite
overflow flag), and if set bumps a cumulative counter at
`$07FD` plus a sticky bit at `$07FC`. The sticky bit clears
on every `wait_frame`.
New debug builtins:
- `debug.sprite_overflow_count()` → u8 peek of $07FD
- `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit)
The hardware flag has well-known quirks but is correct for
the overwhelming majority of cases and costs ~15 cycles per
frame to sample. Release builds emit no overflow-check code
at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay
free for user allocation.
Related changes:
- `gen_nmi` now takes an `NmiOptions` struct. Four bool
parameters tripped clippy's `fn_params_excessive_bools`.
- CLI `build` now renders analyzer warnings on a successful
build. Previously warnings were silently dropped unless
the user also ran `nescript check`, which made W0109
effectively invisible to CI and local dev alike. Existing
pre-existing W0103 / W0106 warnings on `coin_cavern`,
`mmc3_per_state_split`, `sprites_and_palettes` surface
too — not regressions, just now visible.
New example: `examples/sprite_flicker_demo.ne`
Draws 12 sprites into a 4-pixel band, W0109 fires at compile
time with nine labels pointing at the offenders, and a
`cycle_sprites` call at the end of `on frame` turns the
hardware dropout into flicker. The committed emulator golden
captures one frame of the cycling pattern (deterministic).
Tests:
- `runtime::tests::nmi_debug_mode_samples_sprite_overflow`
- `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset`
- `ir_codegen::*::debug_sprite_overflow_count_loads_07fd`
- `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc`
- `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode`
- `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky`
- `ir_codegen::*::cycle_sprites_emits_marker_and_add4`
- `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls`
- `ir_codegen::*::program_without_cycle_sprites_emits_no_marker`
- `analyzer::*::accepts_debug_sprite_overflow_builtins`
- `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names`
- `analyzer::*::accepts_cycle_sprites_statement`
Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three
layers (W0109, `cycle_sprites`, debug telemetry) with reasoning
for when each applies. `README.md` and `examples/README.md` add
the new example to their tables.
All 32 emulator goldens still match — the cycling is opt-in
and programs that don't call `cycle_sprites` or enable debug
mode are byte-identical to the pre-change output.
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 22:07:19 +00:00
|
|
|
// Render any analyzer warnings that survived a successful
|
|
|
|
|
// compile. Errors would have taken the `CompileError::Analyze`
|
|
|
|
|
// path above and returned before we got here, so everything
|
|
|
|
|
// left in `out.analysis.diagnostics` is a warning (W01xx).
|
|
|
|
|
// Without this the CLI would silently swallow every warning
|
|
|
|
|
// on a successful build, making them effectively invisible
|
|
|
|
|
// — the warning machinery in the analyzer would still run,
|
|
|
|
|
// but nobody would ever see its output unless they also
|
|
|
|
|
// invoked `nescript check`.
|
|
|
|
|
if !out.analysis.diagnostics.is_empty() {
|
|
|
|
|
render_diagnostics(&source, &filename, &out.analysis.diagnostics);
|
|
|
|
|
}
|
|
|
|
|
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
// Post-link CLI-only side effects: the various `--dump-*`
|
|
|
|
|
// flags and the two optional file outputs. These are not
|
|
|
|
|
// part of the pipeline because they're stdout / filesystem
|
|
|
|
|
// I/O, not compilation.
|
|
|
|
|
if opts.dump_ir {
|
|
|
|
|
print!("{}", out.ir_program.pretty());
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
}
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
if opts.call_graph {
|
|
|
|
|
print_call_graph(&out.analysis);
|
codegen: user code in switchable banks via cross-bank trampolines
Adds a `bank Foo { fun bar() { ... } }` parser form so user functions
can opt into living in a switchable PRG bank instead of the fixed
bank, plus the IR codegen, runtime, and linker work to make calls
across the bank boundary actually run. Programs that don't use the
new syntax produce byte-identical ROMs to before — verified by
rebuilding every existing example and diffing.
Pipeline shape:
* Parser accepts both `bank Foo: prg` (legacy reserved slot) and
`bank Foo { fun ... }` (functions land in the named bank). Nested
functions get tagged `bank: Some("Foo")` on the FunDecl + IrFunction.
* Analyzer bumps the user zero-page start past `$10` whenever the
program declares any banked function, so `__bank_select`'s STA into
ZP_BANK_CURRENT can't clobber a user variable. Programs without
banked functions keep the legacy `$10` start.
* IrCodeGen emits each banked function into its own per-bank
instruction stream (`banked_streams: HashMap<String, Vec<Instruction>>`)
while the fixed-bank stream gets the dispatcher loop + state
handlers + top-level functions, exactly like before. Cross-bank
calls from the fixed bank rewrite `JSR __ir_fn_<name>` to
`JSR __tramp_<name>`; in-bank calls stay direct. Banked → fixed
calls are direct (the fixed bank is always mapped at $C000-$FFFF).
Banked → other-banked calls aren't supported in this pass and
panic loudly during codegen.
* Runtime's `gen_bank_trampoline` takes the trampoline label and
entry label as parameters now (one trampoline per banked function,
not one per bank) so the linker can request any number of stubs.
* Linker assembles banked banks twice: a discovery pass to learn
each bank's labels, then a final pass that seeds the merged label
table so banked code can JSR into the fixed bank's runtime helpers
(math, audio, etc.). The fixed-bank assembler is also seeded with
the cross-bank labels so the trampolines' `JSR __ir_fn_<name>`
resolves into the bank's $8000 window. New `asm::assemble_with_labels`
/ `asm::assemble_discover_labels` helpers wire this up.
* PrgBank carries `Vec<Instruction>` + a list of `BankTrampoline`
requests now, replacing the old `data: Vec<u8>` + single
`entry_label: Option<String>` shape. The compiler populates both
from the codegen output; the linker's two-pass assembly handles
the rest.
New example: `examples/uxrom_user_banked.ne` puts a sprite-stepping
helper inside `bank Extras { fun step_animation() { ... } }`. The
fixed-bank state handler calls it via the generated trampoline, and
the harness golden locks in pixel + audio output at frame 180.
UxROM is the only mapper exercised by the new example. MMC1 and
MMC3 also work through the same path (the linker emits the right
mapper-specific bank-select code), but no example uses them yet —
the existing `mmc1_banked.ne` / `mmc3_per_state_split.ne` keep
their fixed-bank-only layout.
Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
is untouched).
2026-04-14 11:41:20 +00:00
|
|
|
}
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
if opts.asm_dump {
|
|
|
|
|
dump_asm(&out.instructions);
|
codegen: user code in switchable banks via cross-bank trampolines
Adds a `bank Foo { fun bar() { ... } }` parser form so user functions
can opt into living in a switchable PRG bank instead of the fixed
bank, plus the IR codegen, runtime, and linker work to make calls
across the bank boundary actually run. Programs that don't use the
new syntax produce byte-identical ROMs to before — verified by
rebuilding every existing example and diffing.
Pipeline shape:
* Parser accepts both `bank Foo: prg` (legacy reserved slot) and
`bank Foo { fun ... }` (functions land in the named bank). Nested
functions get tagged `bank: Some("Foo")` on the FunDecl + IrFunction.
* Analyzer bumps the user zero-page start past `$10` whenever the
program declares any banked function, so `__bank_select`'s STA into
ZP_BANK_CURRENT can't clobber a user variable. Programs without
banked functions keep the legacy `$10` start.
* IrCodeGen emits each banked function into its own per-bank
instruction stream (`banked_streams: HashMap<String, Vec<Instruction>>`)
while the fixed-bank stream gets the dispatcher loop + state
handlers + top-level functions, exactly like before. Cross-bank
calls from the fixed bank rewrite `JSR __ir_fn_<name>` to
`JSR __tramp_<name>`; in-bank calls stay direct. Banked → fixed
calls are direct (the fixed bank is always mapped at $C000-$FFFF).
Banked → other-banked calls aren't supported in this pass and
panic loudly during codegen.
* Runtime's `gen_bank_trampoline` takes the trampoline label and
entry label as parameters now (one trampoline per banked function,
not one per bank) so the linker can request any number of stubs.
* Linker assembles banked banks twice: a discovery pass to learn
each bank's labels, then a final pass that seeds the merged label
table so banked code can JSR into the fixed bank's runtime helpers
(math, audio, etc.). The fixed-bank assembler is also seeded with
the cross-bank labels so the trampolines' `JSR __ir_fn_<name>`
resolves into the bank's $8000 window. New `asm::assemble_with_labels`
/ `asm::assemble_discover_labels` helpers wire this up.
* PrgBank carries `Vec<Instruction>` + a list of `BankTrampoline`
requests now, replacing the old `data: Vec<u8>` + single
`entry_label: Option<String>` shape. The compiler populates both
from the codegen output; the linker's two-pass assembly handles
the rest.
New example: `examples/uxrom_user_banked.ne` puts a sprite-stepping
helper inside `bank Extras { fun step_animation() { ... } }`. The
fixed-bank state handler calls it via the generated trampoline, and
the harness golden locks in pixel + audio output at frame 180.
UxROM is the only mapper exercised by the new example. MMC1 and
MMC3 also work through the same path (the linker emits the right
mapper-specific bank-select code), but no example uses them yet —
the existing `mmc1_banked.ne` / `mmc3_per_state_split.ne` keep
their fixed-bank-only layout.
Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
is untouched).
2026-04-14 11:41:20 +00:00
|
|
|
}
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
if opts.memory_map {
|
|
|
|
|
print_memory_map(
|
|
|
|
|
&out.analysis,
|
|
|
|
|
Some(&out.link_result),
|
|
|
|
|
&out.palettes,
|
|
|
|
|
&out.backgrounds,
|
|
|
|
|
);
|
2026-04-14 03:01:32 +00:00
|
|
|
}
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
if let Some(path) = opts.symbols.as_ref() {
|
|
|
|
|
let mlb = render_mlb(&out.link_result, &out.analysis.var_allocations);
|
2026-04-14 02:39:36 +00:00
|
|
|
std::fs::write(path, mlb).map_err(|e| {
|
|
|
|
|
eprintln!("error: failed to write symbol file {}: {e}", path.display());
|
|
|
|
|
})?;
|
|
|
|
|
}
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
if let Some(path) = opts.source_map.as_ref() {
|
|
|
|
|
let map = render_source_map(&out.link_result, &out.source_locs, &source);
|
2026-04-14 02:39:36 +00:00
|
|
|
std::fs::write(path, map).map_err(|e| {
|
|
|
|
|
eprintln!("error: failed to write source map {}: {e}", path.display());
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
|
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.
Fix:
- New `nescript::pipeline` module with `compile_source(source,
source_dir, &CompileOptions)` that owns the full
`parse → analyze → lower → optimize → codegen → peephole →
link` pipeline including the per-bank stream + trampoline
reconstruction. Returns a `CompileOutput` carrying the ROM,
the linker result, analysis, IR, assets, instructions, and
source-loc markers so downstream tools have one place to
pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
a single `compile_source` call + CLI-only side effects
(`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
`--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
`compile_source` call. It is now structurally impossible for
the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
likewise delegates to `compile_source`. This also fixes a
latent bug in the helper where it used `Linker::with_mapper`
without `.with_header(...)` — programs opting into
`header: nes2` would have quietly got an iNES 1.0 header
through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
specify `cargo test --all-targets` (plain `cargo test` skips
benches) and to point at `scripts/pre-commit` with the exact
install command. Also installed the hook in this worktree.
All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
|
|
|
Ok(out.rom)
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn check(input: &PathBuf) -> Result<(), ()> {
|
2026-04-12 10:06:58 +00:00
|
|
|
let raw_source = std::fs::read_to_string(input).map_err(|e| {
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
eprintln!("error: failed to read {}: {e}", input.display());
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-04-12 10:06:58 +00:00
|
|
|
let source = nescript::parser::preprocess_source(&raw_source, Some(input)).map_err(|e| {
|
|
|
|
|
eprintln!("error: {e}");
|
|
|
|
|
})?;
|
|
|
|
|
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
let filename = input.to_string_lossy();
|
|
|
|
|
|
|
|
|
|
let (program, parse_diags) = nescript::parser::parse(&source);
|
|
|
|
|
if !parse_diags.is_empty() {
|
|
|
|
|
render_diagnostics(&source, &filename, &parse_diags);
|
|
|
|
|
}
|
|
|
|
|
if parse_diags
|
|
|
|
|
.iter()
|
|
|
|
|
.any(nescript::errors::Diagnostic::is_error)
|
|
|
|
|
{
|
|
|
|
|
return Err(());
|
|
|
|
|
}
|
|
|
|
|
let program = program.ok_or(())?;
|
|
|
|
|
|
|
|
|
|
let analysis = analyzer::analyze(&program);
|
|
|
|
|
if !analysis.diagnostics.is_empty() {
|
|
|
|
|
render_diagnostics(&source, &filename, &analysis.diagnostics);
|
|
|
|
|
}
|
|
|
|
|
if analysis
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(nescript::errors::Diagnostic::is_error)
|
|
|
|
|
{
|
|
|
|
|
return Err(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-04-14 03:01:32 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use nescript::analyzer::AnalysisResult;
|
|
|
|
|
use nescript::linker::LinkedRom;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
fn empty_analysis() -> AnalysisResult {
|
|
|
|
|
AnalysisResult {
|
|
|
|
|
symbols: HashMap::new(),
|
|
|
|
|
var_allocations: Vec::new(),
|
|
|
|
|
diagnostics: Vec::new(),
|
|
|
|
|
call_graph: HashMap::new(),
|
|
|
|
|
max_depths: HashMap::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn write_memory_map_without_link_result_covers_variable_path() {
|
|
|
|
|
// Without a link result (e.g. the unit-test path that
|
|
|
|
|
// only wants to inspect the variable allocator) the output
|
|
|
|
|
// should still render the Zero Page / RAM sections and the
|
|
|
|
|
// summary lines. No PRG ROM section appears because there
|
|
|
|
|
// are no linked labels to point at.
|
|
|
|
|
let analysis = empty_analysis();
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
write_memory_map(&mut buf, &analysis, None, &[], &[]).unwrap();
|
|
|
|
|
let s = String::from_utf8(buf).unwrap();
|
|
|
|
|
assert!(s.contains("=== NEScript Memory Map ==="));
|
|
|
|
|
assert!(s.contains("Zero Page"));
|
|
|
|
|
assert!(s.contains("0/128 bytes used"));
|
|
|
|
|
assert!(!s.contains("PRG ROM data blobs"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn write_memory_map_reports_palette_and_background_rom_addresses() {
|
|
|
|
|
// With palettes and backgrounds plus a faked LinkedRom
|
|
|
|
|
// carrying matching labels, the PRG ROM section should
|
|
|
|
|
// render each blob's CPU address + size and a grand total.
|
|
|
|
|
let analysis = empty_analysis();
|
|
|
|
|
let palettes = vec![PaletteData {
|
|
|
|
|
name: "Main".to_string(),
|
|
|
|
|
colors: [0u8; 32],
|
|
|
|
|
}];
|
|
|
|
|
let backgrounds = vec![BackgroundData {
|
|
|
|
|
name: "Stage".to_string(),
|
|
|
|
|
tiles: [0u8; 960],
|
|
|
|
|
attrs: [0u8; 64],
|
2026-04-15 03:29:58 +00:00
|
|
|
chr_bytes: Vec::new(),
|
|
|
|
|
chr_base_tile: 0,
|
2026-04-14 03:01:32 +00:00
|
|
|
}];
|
|
|
|
|
let mut labels = HashMap::new();
|
|
|
|
|
labels.insert("__palette_Main".to_string(), 0xC100);
|
|
|
|
|
labels.insert("__bg_tiles_Stage".to_string(), 0xC200);
|
|
|
|
|
labels.insert("__bg_attrs_Stage".to_string(), 0xC5C0);
|
|
|
|
|
let link = LinkedRom {
|
|
|
|
|
rom: Vec::new(),
|
|
|
|
|
labels,
|
|
|
|
|
fixed_bank_file_offset: 16,
|
|
|
|
|
};
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
write_memory_map(&mut buf, &analysis, Some(&link), &palettes, &backgrounds).unwrap();
|
|
|
|
|
let s = String::from_utf8(buf).unwrap();
|
|
|
|
|
assert!(s.contains("PRG ROM data blobs:"));
|
|
|
|
|
assert!(
|
|
|
|
|
s.contains("$C100") && s.contains("[PALETTE] Main"),
|
|
|
|
|
"missing palette line in: {s}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
s.contains("$C200") && s.contains("[BG-TILES] Stage"),
|
|
|
|
|
"missing bg-tiles line in: {s}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
s.contains("$C5C0") && s.contains("[BG-ATTRS] Stage"),
|
|
|
|
|
"missing bg-attrs line in: {s}"
|
|
|
|
|
);
|
|
|
|
|
// 32 (palette) + 960 + 64 (background) = 1056.
|
|
|
|
|
assert!(s.contains("1056 bytes"), "missing total in: {s}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn write_memory_map_marks_unlinked_blobs() {
|
|
|
|
|
// If a palette's label isn't in `link.labels` (e.g. the
|
|
|
|
|
// linker skipped it for some reason), we still emit the
|
|
|
|
|
// line but mark it "(unlinked)" so the user knows the
|
|
|
|
|
// address isn't available.
|
|
|
|
|
let analysis = empty_analysis();
|
|
|
|
|
let palettes = vec![PaletteData {
|
|
|
|
|
name: "Ghost".to_string(),
|
|
|
|
|
colors: [0u8; 32],
|
|
|
|
|
}];
|
|
|
|
|
let link = LinkedRom {
|
|
|
|
|
rom: Vec::new(),
|
|
|
|
|
labels: HashMap::new(),
|
|
|
|
|
fixed_bank_file_offset: 16,
|
|
|
|
|
};
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
write_memory_map(&mut buf, &analysis, Some(&link), &palettes, &[]).unwrap();
|
|
|
|
|
let s = String::from_utf8(buf).unwrap();
|
|
|
|
|
assert!(s.contains("(unlinked)"), "missing unlinked marker in: {s}");
|
|
|
|
|
assert!(s.contains("[PALETTE] Ghost"));
|
|
|
|
|
}
|
|
|
|
|
}
|