1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-15 12:48:15 +00:00
nescript/src/main.rs

605 lines
21 KiB
Rust
Raw Normal View History

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;
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
use std::io::Write as _;
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;
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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;
use nescript::linker::{render_dbg, render_mlb, render_source_map, LinkedRom};
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::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>,
/// Enable debug mode (runtime checks, debug.log)
#[arg(long)]
debug: bool,
/// Dump generated 6502 assembly to stdout
#[arg(long)]
asm_dump: bool,
Implement IR-based code generator (--use-ir) New src/codegen/ir_codegen.rs walks IrProgram and emits 6502 instructions. This enables optimizer passes to actually affect the output ROM. Design: - Each IR temp gets a zero-page slot at $80 + temp_index - Functions reset the temp counter at entry (temps don't outlive functions) - Globals map by name to their analyzer-assigned zero-page addresses - Operands are loaded into A, computed, stored back to the dest slot Handles all IrOp variants: - LoadImm, LoadVar, StoreVar (basic loads/stores) - Add/Sub (CLC+ADC / SEC+SBC) - Mul (JSR __multiply runtime routine) - And/Or/Xor (zero-page operands) - ShiftLeft/ShiftRight (repeated ASL/LSR) - Negate/Complement (EOR #$FF + optional two's complement) - CmpEq/Ne/Lt/Gt/LtEq/GtEq (CMP + conditional branch + 0/1) - ArrayLoad/ArrayStore (TAX + ZeroPageX/AbsoluteX) - Call (ZP param passing + JSR) - DrawSprite (OAM slot 0 write, uses sprite_tiles map) - ReadInput (LDA $01, P1 input) - WaitFrame (poll frame flag at $00) All terminators: - Jump (JMP to block label) - Branch (LDA temp + BNE true / JMP false) - Return (optional value in A + RTS) - Unreachable (BRK) IR lowering fixes: - ReadInput now has a destination IrTemp (was a side-effect-only op) - ButtonRead uses the proper input temp instead of uninitialized register - Logical AND/OR use new emit_move helper (OR with zero) instead of bogus raw VarId for path merging CLI: - New --use-ir flag on `build` subcommand to opt in to IR codegen - Default remains AST codegen (for now); IR codegen is experimental All 7 examples compile through the IR pipeline and produce valid iNES ROMs. Tests: 266 total (7 new ir_codegen unit + 2 new integration). https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:23:43 +00:00
/// Dump the lowered IR program to stdout (after optimization)
#[arg(long)]
dump_ir: bool,
/// Dump a human-readable memory map of variable allocations
/// to stdout.
#[arg(long)]
memory_map: bool,
/// Dump a call graph showing which functions call which.
#[arg(long)]
call_graph: bool,
/// 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,
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
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>,
/// Write a ca65-compatible debug-info file (`.dbg`) next
/// to the ROM. The format is the same one `ld65` emits,
/// so Mesen / Mesen2 / fceuX pick it up automatically and
/// enable source-level stepping, labelled variable
/// inspection, and symbol-based breakpoints. Implies
/// `--source-map`-style `__src_<N>` marker emission so
/// line records have something to point at.
#[arg(long, value_name = "PATH")]
dbg: 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 {
Cli::Build {
input,
output,
debug,
asm_dump,
dump_ir,
memory_map,
call_graph,
no_opt,
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
symbols,
source_map,
dbg,
} => {
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"));
match compile(
&input,
&output,
&CompileOptions {
debug,
asm_dump,
dump_ir,
memory_map,
call_graph,
no_opt,
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
symbols: symbols.clone(),
source_map: source_map.clone(),
dbg: dbg.clone(),
},
) {
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_or(0, |m| m.len())
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
);
}
Err(()) => std::process::exit(1),
}
}
Cli::Check { input } => match check(&input) {
Ok(()) => println!("no errors found in {}", input.display()),
Err(()) => std::process::exit(1),
},
}
}
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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<()> {
let mut allocs: Vec<_> = analysis.var_allocations.iter().collect();
allocs.sort_by_key(|a| a.address);
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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)"
)?;
for a in allocs.iter().filter(|a| a.address < 0x100) {
if a.size == 1 {
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?;
} else {
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
writeln!(
w,
" ${:04X}-${:04X} [USER] {} ({} bytes)",
a.address,
a.address + a.size - 1,
a.name,
a.size
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
)?;
}
}
let ram_allocs: Vec<_> = allocs.iter().filter(|a| a.address >= 0x100).collect();
if !ram_allocs.is_empty() {
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
writeln!(w, "\nRAM ($0200-$07FF):")?;
writeln!(w, " $0200-$02FF [SYSTEM] OAM shadow buffer")?;
for a in &ram_allocs {
if a.size == 1 {
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
writeln!(w, " ${:04X} [USER] {} (u8)", a.address, a.name)?;
} else {
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
writeln!(
w,
" ${:04X}-${:04X} [USER] {} ({} bytes)",
a.address,
a.address + a.size - 1,
a.name,
a.size
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +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();
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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();
}
/// 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}");
}
}
}
}
fn dump_asm(instructions: &[nescript::asm::Instruction]) {
use nescript::asm::{AddressingMode, Opcode};
for inst in instructions {
// 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;
}
}
println!(" {:?} {:?}", inst.opcode, inst.mode);
}
}
#[allow(clippy::struct_excessive_bools)]
struct CompileOptions {
debug: bool,
asm_dump: bool,
dump_ir: bool,
memory_map: bool,
call_graph: bool,
no_opt: bool,
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
symbols: Option<PathBuf>,
source_map: Option<PathBuf>,
dbg: Option<PathBuf>,
}
fn compile(input: &PathBuf, output: &Path, 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.
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());
})?;
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 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.
//
// `--dbg` reuses the same `__src_<N>` markers that
// `--source-map` emits, so either flag flips on source-loc
// emission in the codegen.
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
let pipeline_opts = PipelineOptions {
debug: opts.debug,
no_opt: opts.no_opt,
emit_source_map: opts.source_map.is_some() || opts.dbg.is_some(),
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
};
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}");
}
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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());
}
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,
);
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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);
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
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);
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
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());
})?;
}
if let Some(path) = opts.dbg.as_ref() {
let dbg = render_dbg(
&out.link_result,
&out.source_locs,
&out.analysis.var_allocations,
&source,
input,
output,
);
std::fs::write(path, dbg).map_err(|e| {
eprintln!("error: failed to write dbg file {}: {e}", path.display());
})?;
}
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +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
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<(), ()> {
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());
})?;
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(())
}
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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],
assets: auto-generate CHR data from @nametable() PNG sources `background Foo @nametable("file.png")` previously decoded the PNG into a tile-index table and an attribute table but left CHR generation to the user — they had to supply matching tiles via a separate `sprite Tileset @chr(...)` declaration in the same deduplication order, which was both error-prone and the main thing keeping the shortcut form from being a one-liner. The CHR pipeline now closes the gap. `png_to_nametable_with_chr` returns a `PngNametable` carrying the tile-index table, the attribute table, *and* a per-tile CHR blob encoded with the same brightness-bucketing `png_to_chr` already uses for sprites. The resolver passes `next_sprite_tile` (computed from the resolved sprite list) so each background's CHR allocation slots in immediately after the sprite range, and rewrites the nametable indices to point at the actual physical tile numbers. The linker copies each background's `chr_bytes` into CHR ROM at `chr_base_tile * 16`, so the final image renders without any user-supplied CHR. `BackgroundData` carries `chr_bytes` and `chr_base_tile` so the linker has everything it needs at a glance. Inline `tiles:` / `attributes:` declarations leave them empty and behave exactly like before — that path doesn't auto-generate CHR because the user is implicitly opting into "I'll provide tiles myself" by typing the indices out by hand. The new `examples/auto_chr_background.ne` is a 256×240 grayscale gradient committed alongside its `auto_chr_bg.png` source; the emulator harness verifies the rendered output against a committed golden so a regression in the dedupe/encode/linker plumbing fails CI loudly. Existing example ROMs are byte- identical because their backgrounds either have no PNG source or already provided their own CHR. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:29:58 +00:00
chr_bytes: Vec::new(),
chr_base_tile: 0,
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting Implements three items from docs/future-work.md's "PNG-sourced palette and nametable assets" section: - `palette Name @palette("file.png")` — the parser accepts a PNG shortcut form; the asset resolver decodes the image via the new `png_to_palette` helper, mapping each pixel's RGB to the nearest NES master-palette index and building a 32-byte blob that enforces the universal-first-byte convention (same as the grouped-form parser). Errors cleanly on missing files or more than 16 unique colours. - `background Name @nametable("file.png")` — the parser accepts a PNG shortcut form; the resolver decodes a 256×240 image into a 960-byte tile-index table (deduplicating up to 256 unique 8×8 tiles) plus a 64-byte attribute table (bucketed by average quadrant brightness). CHR data is not yet generated automatically — callers still need to provide matching CHR via the existing sprite / `@chr(...)` pipeline; the limitation is documented on the `png_to_nametable` helper and can be lifted in a follow-up. - `--memory-map` now prints a "PRG ROM data blobs" section listing each palette (32 B) and background (960 + 64 B) under its linker-assigned label, plus a grand total. The memory-map code is factored into `write_memory_map` which takes a writer so unit tests can drive it against a `Vec<u8>`. Memory-map printing moved to after the link step so palette/background CPU addresses are available. Call-site changes: `resolve_palettes` and `resolve_backgrounds` now take a `source_dir` path and return `Result<_, String>` because PNG decoding can fail. Updated the CLI driver, benches/compile.rs, and every integration-test compile helper. All 23 committed examples rebuild byte-identical; 525 lib tests + 72 integration tests + 3 bin tests pass; clippy clean.
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"));
}
}