2026-04-12 10:01:44 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
|
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap
143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)
CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
|
|
|
use nescript::analyzer;
|
2026-04-12 10:01:44 +00:00
|
|
|
use nescript::assets;
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
use nescript::codegen::IrCodeGen;
|
2026-04-11 23:34:35 +00:00
|
|
|
use nescript::ir;
|
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
|
|
|
use nescript::linker::{Linker, PrgBank};
|
2026-04-11 23:34:35 +00:00
|
|
|
use nescript::optimizer;
|
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
|
|
|
use nescript::parser::ast::BankType;
|
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::rom;
|
|
|
|
|
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
/// Compile a `NEScript` source string into a .nes ROM. Runs the full
|
|
|
|
|
/// IR pipeline: parse → analyze → IR lower → optimize → IR codegen
|
|
|
|
|
/// → peephole → link. This is what the `nescript build` CLI does
|
|
|
|
|
/// (minus file IO and the dump flags), so these integration tests
|
|
|
|
|
/// exercise the same path end users hit.
|
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 compile(source: &str) -> Vec<u8> {
|
|
|
|
|
let (program, diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(
|
|
|
|
|
diags.is_empty(),
|
|
|
|
|
"unexpected parse errors: {diags:?}\nsource:\n{source}"
|
|
|
|
|
);
|
|
|
|
|
let program = program.expect("parse should succeed");
|
|
|
|
|
|
|
|
|
|
let analysis = analyzer::analyze(&program);
|
|
|
|
|
assert!(
|
|
|
|
|
analysis.diagnostics.iter().all(|d| !d.is_error()),
|
|
|
|
|
"unexpected analysis errors: {:?}",
|
|
|
|
|
analysis.diagnostics
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-11 23:34:35 +00:00
|
|
|
let mut ir_program = ir::lower(&program, &analysis);
|
|
|
|
|
optimizer::optimize(&mut ir_program);
|
|
|
|
|
|
2026-04-12 10:01:44 +00:00
|
|
|
let sprites = assets::resolve_sprites(&program, Path::new("."))
|
|
|
|
|
.expect("sprite resolution should succeed");
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed");
|
|
|
|
|
let music = assets::resolve_music(&program).expect("music resolution should succeed");
|
2026-04-12 10:01:44 +00:00
|
|
|
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
|
|
|
|
.with_sprites(&sprites)
|
|
|
|
|
.with_audio(&sfx, &music);
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let mut instructions = codegen.generate(&ir_program);
|
|
|
|
|
nescript::codegen::peephole::optimize(&mut instructions);
|
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 linker = Linker::new(program.game.mirroring);
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
linker.link_with_all_assets(&instructions, &sprites, &sfx, &music)
|
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
|
|
|
}
|
|
|
|
|
|
2026-04-11 23:34:35 +00:00
|
|
|
// ── M1 Tests ──
|
|
|
|
|
|
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
|
|
|
#[test]
|
|
|
|
|
fn hello_sprite_compiles_to_valid_rom() {
|
|
|
|
|
let source = include_str!("integration/hello_sprite.ne");
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
|
|
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.prg_banks, 1, "should be 1 PRG bank (16 KB)");
|
|
|
|
|
assert_eq!(info.chr_banks, 1, "should have CHR ROM");
|
|
|
|
|
assert_eq!(info.mapper, 0, "should be NROM (mapper 0)");
|
|
|
|
|
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn hello_sprite_has_correct_vectors() {
|
|
|
|
|
let source = include_str!("integration/hello_sprite.ne");
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
|
|
|
|
|
let prg_end = 16 + 16384;
|
|
|
|
|
let nmi = u16::from_le_bytes([rom_data[prg_end - 6], rom_data[prg_end - 5]]);
|
|
|
|
|
let reset = u16::from_le_bytes([rom_data[prg_end - 4], rom_data[prg_end - 3]]);
|
|
|
|
|
let irq = u16::from_le_bytes([rom_data[prg_end - 2], rom_data[prg_end - 1]]);
|
|
|
|
|
|
|
|
|
|
assert!(nmi >= 0xC000, "NMI vector should be in ROM space");
|
|
|
|
|
assert_eq!(reset, 0xC000, "RESET should point to $C000");
|
|
|
|
|
assert!(irq >= 0xC000, "IRQ vector should be in ROM space");
|
|
|
|
|
assert!(nmi != reset, "NMI and RESET should be different");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn minimal_program_compiles() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Minimal" { mapper: NROM }
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_state_machine() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "States" { mapper: NROM }
|
|
|
|
|
|
|
|
|
|
state Title {
|
|
|
|
|
on frame {
|
|
|
|
|
if button.start { transition Game }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state Game {
|
|
|
|
|
var score: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
score += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start Title
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_constants() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Constants" { mapper: NROM }
|
|
|
|
|
const SPEED: u8 = 3
|
|
|
|
|
var px: u8 = 100
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { px += SPEED }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 23:34:35 +00:00
|
|
|
// ── M2 Tests ──
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_functions() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Functions" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
|
|
|
|
|
fun add_ten(val: u8) -> u8 {
|
|
|
|
|
return val + 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
x = add_ten(5)
|
|
|
|
|
}
|
2026-04-12 11:39:12 +00:00
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
2026-04-12 16:22:54 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_on_scanline_mmc3() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Scanline" { mapper: MMC3 }
|
|
|
|
|
var sx: u8 = 0
|
|
|
|
|
state Main {
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
on scanline(120) { scroll(sx, 0) }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
2026-04-12 16:29:15 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_on_scanline_per_state() {
|
|
|
|
|
// Two states, each with its own scanline handler at a different
|
|
|
|
|
// position. The IR codegen should emit per-state dispatch in
|
|
|
|
|
// both `__irq_user` and `__ir_mmc3_reload`.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "MultiSL" { mapper: MMC3 }
|
|
|
|
|
var s: u8 = 0
|
|
|
|
|
state A {
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
on scanline(64) { scroll(0, 0) }
|
|
|
|
|
}
|
|
|
|
|
state B {
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
on scanline(192) { scroll(0, 0) }
|
|
|
|
|
}
|
|
|
|
|
start A
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
2026-04-12 16:55:18 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:01:12 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_function_local_variables() {
|
|
|
|
|
// Functions with locally-declared variables should allocate
|
|
|
|
|
// their own backing storage and not corrupt caller state when
|
|
|
|
|
// nested.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Locals" { mapper: NROM }
|
|
|
|
|
var out: u8 = 0
|
|
|
|
|
|
|
|
|
|
fun double(x: u8) -> u8 {
|
|
|
|
|
var t: u8 = x
|
|
|
|
|
t = t + t
|
|
|
|
|
return t
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun double_sum(a: u8, b: u8) -> u8 {
|
|
|
|
|
var s1: u8 = double(a)
|
|
|
|
|
var s2: u8 = double(b)
|
|
|
|
|
return s1 + s2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
out = double_sum(10, 20)
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:55:18 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_for_loop() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "ForLoop" { mapper: NROM }
|
|
|
|
|
var arr: u8[8] = [0, 0, 0, 0, 0, 0, 0, 0]
|
|
|
|
|
var total: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
total = 0
|
|
|
|
|
for i in 0..8 {
|
|
|
|
|
total += arr[i]
|
|
|
|
|
}
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
2026-04-12 17:15:57 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:21:00 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_match_statement() {
|
|
|
|
|
// Note: the parser doesn't support `;` as a statement separator,
|
|
|
|
|
// so each arm body uses newlines between statements.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Match" { mapper: NROM }
|
|
|
|
|
enum Mode { Idle, Run, Jump }
|
|
|
|
|
var mode: u8 = Idle
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
match mode {
|
|
|
|
|
Idle => { if button.a { mode = Run } }
|
|
|
|
|
Run => {
|
|
|
|
|
x += 1
|
|
|
|
|
if button.b { mode = Jump }
|
|
|
|
|
}
|
|
|
|
|
Jump => {
|
|
|
|
|
x += 2
|
|
|
|
|
if button.a { mode = Idle }
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:15:57 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_struct_literals() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Lit" { mapper: NROM }
|
|
|
|
|
struct Vec2 { x: u8, y: u8 }
|
|
|
|
|
var pos: Vec2 = Vec2 { x: 10, y: 20 }
|
|
|
|
|
on frame {
|
|
|
|
|
pos = Vec2 { x: 100, y: 50 }
|
|
|
|
|
if button.right {
|
|
|
|
|
pos = Vec2 { x: pos.x + 1, y: pos.y }
|
|
|
|
|
}
|
|
|
|
|
draw Smiley at: (pos.x, pos.y)
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
2026-04-12 11:39:12 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:18:05 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_structs() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Structs" { mapper: NROM }
|
|
|
|
|
struct Vec2 { x: u8, y: u8 }
|
|
|
|
|
struct Player { health: u8, lives: u8 }
|
|
|
|
|
|
|
|
|
|
var pos: Vec2
|
|
|
|
|
var hero: Player
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
pos.x = 100
|
|
|
|
|
pos.y = 50
|
|
|
|
|
hero.health = 3
|
|
|
|
|
hero.lives = 5
|
|
|
|
|
if button.right { pos.x += 1 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:39:12 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_enums() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Enums" { mapper: NROM }
|
|
|
|
|
enum Direction { Up, Down, Left, Right }
|
|
|
|
|
enum Mode { Idle, Running, Jumping }
|
|
|
|
|
|
|
|
|
|
var dir: u8 = 0
|
|
|
|
|
var mode: u8 = 0
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { dir = Right }
|
|
|
|
|
if button.left { dir = Left }
|
|
|
|
|
if dir == Right { mode = Running }
|
|
|
|
|
}
|
2026-04-12 17:30:21 +00:00
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:40:34 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_poke_peek_intrinsics() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Hardware" { mapper: NROM }
|
|
|
|
|
var status: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
// Write to PPU address / data registers directly.
|
|
|
|
|
poke(0x2006, 0x3F)
|
|
|
|
|
poke(0x2006, 0x00)
|
|
|
|
|
poke(0x2007, 0x0F)
|
|
|
|
|
// Read PPU status.
|
|
|
|
|
status = peek(0x2002)
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:34:17 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_raw_asm_block() {
|
|
|
|
|
// `raw asm` bypasses `{var}` substitution so the body is passed
|
|
|
|
|
// to the inline parser unchanged.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "RawAsm" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
raw asm {
|
|
|
|
|
LDA #$42
|
|
|
|
|
STA $00
|
|
|
|
|
}
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:30:21 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_inline_asm_variable_substitution() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "AsmVar" { mapper: NROM }
|
|
|
|
|
var counter: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
asm {
|
|
|
|
|
LDA {counter}
|
|
|
|
|
CLC
|
|
|
|
|
ADC #$01
|
|
|
|
|
STA {counter}
|
|
|
|
|
}
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
2026-04-11 23:34:35 +00:00
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
Inline assembly: asm { ... } blocks
- Lexer: after \`asm\` keyword, next \`{\` triggers raw-text capture of
the body until the matching \`}\`, emitted as a new \`AsmBody\` token
- Parser: \`asm { ... }\` produces \`Statement::InlineAsm(body, span)\`
- Analyzer: treats inline asm as opaque (no checks)
- IR: new \`IrOp::InlineAsm(String)\` variant that passes the body
through the optimizer unchanged
- \`src/asm/inline_parser.rs\`: minimal 6502 mnemonic parser supporting
every addressing mode we emit elsewhere (immediate, ZP/ABS with X/Y,
indirect, indirect-X/Y, labels, branches, implied, accumulator)
- Both IR and AST codegen splice parsed instructions inline
- Integration test covers a mix of implied + immediate + ZP + A modes
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:16:18 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_inline_asm() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Asm" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
asm {
|
|
|
|
|
LDA #$42
|
|
|
|
|
STA $10
|
|
|
|
|
INC $10
|
|
|
|
|
LSR A
|
|
|
|
|
CLC
|
|
|
|
|
ADC #$01
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 23:34:35 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_while_loop() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Loops" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
while x < 10 {
|
|
|
|
|
x += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_fast_slow_vars() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Placement" { mapper: NROM }
|
|
|
|
|
fast var hot: u8 = 0
|
|
|
|
|
slow var cold: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
hot += 1
|
|
|
|
|
cold += 1
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_multi_state_transitions() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Multi" { mapper: NROM }
|
|
|
|
|
|
|
|
|
|
state Menu {
|
|
|
|
|
on enter { wait_frame }
|
|
|
|
|
on frame {
|
|
|
|
|
if button.start { transition Level1 }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state Level1 {
|
|
|
|
|
var timer: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
timer += 1
|
|
|
|
|
if timer > 60 {
|
|
|
|
|
transition Level2
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state Level2 {
|
|
|
|
|
on frame {
|
|
|
|
|
if button.select { transition Menu }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start Menu
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn coin_cavern_compiles() {
|
|
|
|
|
let source = include_str!("../examples/coin_cavern.ne");
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_pipeline_produces_ir() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "IR" { mapper: NROM }
|
|
|
|
|
const SPEED: u8 = 2
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
fun double(n: u8) -> u8 { return n + n }
|
|
|
|
|
on frame {
|
|
|
|
|
x += SPEED
|
|
|
|
|
if x > 100 { x = 0 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let (program, diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(diags.is_empty());
|
|
|
|
|
let program = program.unwrap();
|
|
|
|
|
let analysis = analyzer::analyze(&program);
|
|
|
|
|
assert!(analysis.diagnostics.iter().all(|d| !d.is_error()));
|
|
|
|
|
|
|
|
|
|
let mut ir_program = ir::lower(&program, &analysis);
|
|
|
|
|
let before_ops = ir_program.op_count();
|
|
|
|
|
optimizer::optimize(&mut ir_program);
|
|
|
|
|
let after_ops = ir_program.op_count();
|
|
|
|
|
|
|
|
|
|
// Optimizer should reduce or maintain op count (not increase)
|
|
|
|
|
assert!(after_ops <= before_ops, "optimizer should not increase ops");
|
|
|
|
|
// Should have functions for the user function + frame handler
|
|
|
|
|
assert!(ir_program.functions.len() >= 2);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
#[test]
|
|
|
|
|
fn error_test_missing_game() {
|
|
|
|
|
let source = "var x: u8 = 0\nstart Main";
|
|
|
|
|
let (_, diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(
|
|
|
|
|
diags.iter().any(nescript::errors::Diagnostic::is_error),
|
|
|
|
|
"should produce error"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_test_undefined_transition() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
state Main {
|
|
|
|
|
on frame { transition Nonexistent }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let (program, parse_diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(parse_diags.is_empty());
|
|
|
|
|
let analysis = analyzer::analyze(&program.unwrap());
|
|
|
|
|
assert!(
|
|
|
|
|
analysis
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(nescript::errors::Diagnostic::is_error),
|
|
|
|
|
"should detect undefined transition target"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-11 23:34:35 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn error_test_recursion_detected() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
fun loop_forever() { loop_forever() }
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let (program, parse_diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(parse_diags.is_empty());
|
|
|
|
|
let analysis = analyzer::analyze(&program.unwrap());
|
|
|
|
|
assert!(
|
|
|
|
|
analysis
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| d.code == nescript::errors::ErrorCode::E0402),
|
|
|
|
|
"should detect recursion"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-12 00:09:47 +00:00
|
|
|
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
// ── M4 Tests ──
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_scroll_and_cast() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "M4 Test" { mapper: NROM }
|
|
|
|
|
var px: u8 = 0
|
|
|
|
|
var py: u8 = 0
|
|
|
|
|
var wide: u16 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { px += 1 }
|
|
|
|
|
wide = px as u16
|
|
|
|
|
scroll(px, py)
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling
Five language features and optimizations from the planned-work backlog:
- **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate
APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and
the NMI handler gains a `JSR __audio_tick` splice (via the linker's
`__audio_used` marker lookup) that ages an SFX countdown counter and
mutes pulse 1 when the tone expires. Programs that never trigger
audio pay zero ROM cost.
- **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`,
`Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context
tracks variable types via the analyzer's symbol table and routes
expressions through the 8-bit or 16-bit path based on operand width.
Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the
high byte; compares dispatch high-byte-first with a short-circuit
low-byte fallback. Fixes a silent miscompile where `big += 1` on a
u16 var only incremented the low byte.
- **Multi-scanline handlers per state**: `gen_scanline_irq` now
dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the
MMC3 counter with the delta to the next scanline in the same state.
`gen_scanline_reload` resets the step counter at the top of each
NMI so a state with multiple handlers fires them in ascending line
order. Previously only the first handler per state ever fired.
- **IR temp slot recycling**: `build_use_counts` pre-scans each
function to count per-temp uses; `retire_op_sources` decrements
the counts after each op and pushes dead slots back onto
`free_slots` for later allocation. `bitwise_ops.ne` used to crash
(debug) or miscompile (release) once it hit 128 concurrent temps;
with recycling the same function now uses ~4 slots instead of 136.
- **INC/DEC peephole fold + improved dead-load elimination**:
`fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into
a single `INC addr` (and the SEC/SBC variant into `DEC addr`),
saving 5 bytes and 5 cycles per increment. The fold is suppressed
when the next instruction reads carry. `remove_dead_loads` now
walks past INC/DEC/STX/STY (which don't touch A) to find the
actual next A-use, catching more dead loads.
Tests: 331 unit + 39 integration (up from 313 + 37), including new
guards for audio, u16, multi-scanline, and slot recycling.
https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:21:53 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_u16_arithmetic_and_compare() {
|
|
|
|
|
// Exercises the full u16 path: literal > 255 initializer,
|
|
|
|
|
// u16 += u8, u16 > u16 comparison. The old codegen truncated
|
|
|
|
|
// all u16 operations to their low byte, so `big = 1000`
|
|
|
|
|
// landed as 232 and `big += 1` never carried into the high
|
|
|
|
|
// byte. This test just asserts the ROM builds cleanly — the
|
|
|
|
|
// unit tests in `codegen/ir_codegen.rs` verify the actual
|
|
|
|
|
// instruction shape.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "U16 Arith" { mapper: NROM }
|
|
|
|
|
var big: u16 = 1000
|
|
|
|
|
var flag: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
big = big + 1
|
|
|
|
|
if big > 1050 {
|
|
|
|
|
flag = 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_audio_driver() {
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
// Exercises the audio driver end-to-end with builtin sfx/music
|
|
|
|
|
// names: play, start_music, stop_music all lower into the
|
|
|
|
|
// data-driven driver, the linker splices the tick/period-table/
|
|
|
|
|
// data blobs, and the resulting ROM is valid iNES.
|
compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling
Five language features and optimizations from the planned-work backlog:
- **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate
APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and
the NMI handler gains a `JSR __audio_tick` splice (via the linker's
`__audio_used` marker lookup) that ages an SFX countdown counter and
mutes pulse 1 when the tone expires. Programs that never trigger
audio pay zero ROM cost.
- **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`,
`Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context
tracks variable types via the analyzer's symbol table and routes
expressions through the 8-bit or 16-bit path based on operand width.
Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the
high byte; compares dispatch high-byte-first with a short-circuit
low-byte fallback. Fixes a silent miscompile where `big += 1` on a
u16 var only incremented the low byte.
- **Multi-scanline handlers per state**: `gen_scanline_irq` now
dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the
MMC3 counter with the delta to the next scanline in the same state.
`gen_scanline_reload` resets the step counter at the top of each
NMI so a state with multiple handlers fires them in ascending line
order. Previously only the first handler per state ever fired.
- **IR temp slot recycling**: `build_use_counts` pre-scans each
function to count per-temp uses; `retire_op_sources` decrements
the counts after each op and pushes dead slots back onto
`free_slots` for later allocation. `bitwise_ops.ne` used to crash
(debug) or miscompile (release) once it hit 128 concurrent temps;
with recycling the same function now uses ~4 slots instead of 136.
- **INC/DEC peephole fold + improved dead-load elimination**:
`fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into
a single `INC addr` (and the SEC/SBC variant into `DEC addr`),
saving 5 bytes and 5 cycles per increment. The fold is suppressed
when the next instruction reads carry. `remove_dead_loads` now
walks past INC/DEC/STX/STY (which don't touch A) to find the
actual next A-use, catching more dead loads.
Tests: 331 unit + 39 integration (up from 313 + 37), including new
guards for audio, u16, multi-scanline, and slot recycling.
https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:21:53 +00:00
|
|
|
let source = r#"
|
|
|
|
|
game "Audio" { mapper: NROM }
|
|
|
|
|
on frame {
|
|
|
|
|
if button.a { play coin }
|
|
|
|
|
if button.b { start_music theme }
|
|
|
|
|
if button.start { stop_music }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_user_declared_sfx_and_music() {
|
|
|
|
|
// Full user-declared audio pipeline: `sfx` and `music` blocks,
|
|
|
|
|
// references via `play`/`start_music`, full ROM emission. The
|
|
|
|
|
// resolved envelope and note-stream bytes should land in PRG
|
|
|
|
|
// under stable labels so the IR codegen's SymbolLo/SymbolHi
|
|
|
|
|
// references resolve.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Audio Assets" { mapper: NROM }
|
|
|
|
|
|
|
|
|
|
sfx Zap {
|
|
|
|
|
duty: 2
|
|
|
|
|
pitch: [0x20, 0x22, 0x24, 0x26, 0x28, 0x2A]
|
|
|
|
|
volume: [15, 13, 11, 9, 6, 3]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
music Loop {
|
|
|
|
|
duty: 2
|
|
|
|
|
volume: 10
|
|
|
|
|
repeat: true
|
|
|
|
|
notes: [37, 8, 41, 8, 44, 8, 49, 8]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var t: u8 = 0
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
t += 1
|
|
|
|
|
if t == 30 { play Zap }
|
|
|
|
|
if t == 60 {
|
|
|
|
|
t = 0
|
|
|
|
|
start_music Loop
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
|
|
|
|
|
// Verify the user-declared envelope appears in PRG. The
|
|
|
|
|
// resolver encodes `Zap` as
|
|
|
|
|
// duty << 6 | 0x30 | volume
|
|
|
|
|
// per frame, terminated by a zero sentinel.
|
|
|
|
|
let prg = &rom_data[16..16 + 16384];
|
|
|
|
|
let env = |v: u8| (2u8 << 6) | 0x30u8 | v;
|
|
|
|
|
let zap_env: [u8; 7] = [env(15), env(13), env(11), env(9), env(6), env(3), 0x00];
|
|
|
|
|
assert!(
|
|
|
|
|
prg.windows(zap_env.len()).any(|w| w == zap_env),
|
|
|
|
|
"Zap envelope bytes should be in PRG ROM"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Verify the music stream is in PRG: (37, 8, 41, 8, 44, 8, 49, 8, 0xFF, 0xFF)
|
|
|
|
|
let loop_stream: [u8; 10] = [37, 8, 41, 8, 44, 8, 49, 8, 0xFF, 0xFF];
|
|
|
|
|
assert!(
|
|
|
|
|
prg.windows(loop_stream.len()).any(|w| w == loop_stream),
|
|
|
|
|
"Loop music note stream should be in PRG ROM"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_without_audio_has_no_audio_driver_in_prg() {
|
|
|
|
|
// Programs that never touch audio should pay zero ROM cost:
|
|
|
|
|
// no period table, no driver body, no data blobs. We verify
|
|
|
|
|
// indirectly by checking that the `__audio_tick` entry point
|
|
|
|
|
// wouldn't have anything to JSR to (because the NMI splice
|
|
|
|
|
// is gated on the `__audio_used` marker which never exists).
|
|
|
|
|
//
|
|
|
|
|
// The cheapest observable signal: a period-table fingerprint.
|
|
|
|
|
// The period table always starts with a distinct 2-byte
|
|
|
|
|
// sequence that appears at C1's period; if we don't see it in
|
|
|
|
|
// PRG, the audio subsystem wasn't linked in.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Silent" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame { x += 1 }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
// Pull the period table for C1 and make sure it's NOT in PRG.
|
|
|
|
|
// C1 ≈ 32.7 Hz → period ≈ 3421 → but that's too big for 11
|
|
|
|
|
// bits, so it clamps. Instead, use the distinctive combined
|
|
|
|
|
// LDA #imm / LDA #imm pattern from the audio tick itself that
|
|
|
|
|
// would only appear if the driver body was linked in.
|
|
|
|
|
//
|
|
|
|
|
// A robust fingerprint: the `JSR __audio_tick` opcode byte
|
|
|
|
|
// ($20) followed by any 2 bytes only appears in the NMI
|
|
|
|
|
// handler when audio was used. We test the absence of the
|
|
|
|
|
// label instead via an indirect method: count the total
|
|
|
|
|
// number of STA $4004 writes (pulse-2 register). When audio
|
|
|
|
|
// is unused, there should be none. When audio is used, there
|
|
|
|
|
// would be several in the driver.
|
|
|
|
|
let prg = &rom_data[16..16 + 16384];
|
|
|
|
|
// `STA $4006` ($8D $06 $40) is written exclusively by the
|
|
|
|
|
// music tick's period-lookup path. The init code pre-silences
|
|
|
|
|
// $4004 but never touches $4006, so its presence is a reliable
|
|
|
|
|
// "the audio driver was linked in" signal.
|
|
|
|
|
let pattern: [u8; 3] = [0x8D, 0x06, 0x40];
|
|
|
|
|
let count = prg.windows(pattern.len()).filter(|w| *w == pattern).count();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
count, 0,
|
|
|
|
|
"silent program should not contain the music tick's $4006 write"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_sfx_name_is_a_hard_error() {
|
|
|
|
|
// The analyzer must reject `play NoSuchSfx` (neither a user
|
|
|
|
|
// decl nor a builtin) with E0505. Regression test for the
|
|
|
|
|
// old behavior, which silently accepted any name.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "T" { mapper: NROM }
|
|
|
|
|
on frame { play NoSuchSfx }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let (program, _) = nescript::parser::parse(source);
|
|
|
|
|
let analysis = analyzer::analyze(&program.unwrap());
|
|
|
|
|
assert!(
|
|
|
|
|
analysis
|
|
|
|
|
.diagnostics
|
|
|
|
|
.iter()
|
|
|
|
|
.any(nescript::errors::Diagnostic::is_error),
|
|
|
|
|
"unknown sfx should produce an error"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn audio_pipeline_drops_period_table_cost_when_unused() {
|
|
|
|
|
// Regression test for the "no-cost elision" invariant: a
|
|
|
|
|
// program with no audio statements should produce a ROM
|
|
|
|
|
// smaller than one that uses audio. The exact byte count
|
|
|
|
|
// varies with codegen changes, so we test the *ordering* of
|
|
|
|
|
// sizes: a silent program < an audio program.
|
|
|
|
|
let silent = compile(
|
|
|
|
|
r#"
|
|
|
|
|
game "Silent" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame { x += 1 }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
// Both ROMs are the same file size (16 header + 16 KB PRG + 8
|
|
|
|
|
// KB CHR = 24592), but the silent program's PRG fills with
|
|
|
|
|
// $FF padding past the code; an audio program's PRG has the
|
|
|
|
|
// driver and tables eating into that padding space. So we
|
|
|
|
|
// count $FF bytes in PRG: the silent version must have more.
|
|
|
|
|
let audio = compile(
|
|
|
|
|
r#"
|
|
|
|
|
game "Audio" { mapper: NROM }
|
|
|
|
|
on frame { play coin }
|
|
|
|
|
start Main
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let silent_prg = &silent[16..16 + 16384];
|
|
|
|
|
let audio_prg = &audio[16..16 + 16384];
|
|
|
|
|
// Count padding bytes ($FF = PRG fill) in each ROM. Using a
|
|
|
|
|
// raw filter().count() is clippy-noisy ("naive_bytecount"),
|
|
|
|
|
// but pulling in the `bytecount` crate for a one-line test
|
|
|
|
|
// helper isn't worth it — the test runs once per build.
|
|
|
|
|
#[allow(clippy::naive_bytecount)]
|
|
|
|
|
let silent_ff = silent_prg.iter().filter(|&&b| b == 0xFF).count();
|
|
|
|
|
#[allow(clippy::naive_bytecount)]
|
|
|
|
|
let audio_ff = audio_prg.iter().filter(|&&b| b == 0xFF).count();
|
|
|
|
|
assert!(
|
|
|
|
|
silent_ff > audio_ff,
|
|
|
|
|
"silent program should have more $FF padding than an audio program \
|
|
|
|
|
(silent={silent_ff}, audio={audio_ff})"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 00:09:47 +00:00
|
|
|
// ── M3 Tests ──
|
|
|
|
|
|
|
|
|
|
#[test]
|
cleanup: fix silent miscompiles and delete dead code exposed by code review
Two correctness bugs were silently producing wrong ROMs:
- `x << n` / `x >> n` always shifted by 1, regardless of `n`, because
the IR lowering for `BinOp::ShiftLeft`/`ShiftRight` hardcoded the
count. Now eval_const the RHS into a compile-time count; fall back
to a new `IrOp::ShiftLeftVar` / `ShiftRightVar` (runtime loop) when
the amount isn't constant. Strength reduction folds the variable
form back to a fixed count once the optimizer knows the value.
- `x / n` / `x % n` always returned 0, because the lowering emitted
`LoadImm(t, 0)` for `BinOp::Div`/`Mod` with a comment saying the
runtime call was "TODO for now". Added real `IrOp::Div` and
`IrOp::Mod`, wired them through use-counting and DCE, gave codegen
`__divide`-based implementations, and taught strength reduction to
rewrite power-of-two divisors into shifts and modulo-by-2ⁿ into
AND masks. Constant folding now handles `Mul`/`Div`/`Mod`/shifts
too, which were previously left for the codegen to emit inefficient
software calls.
Dead code removed (no backward-compat shims kept):
- `src/debug/` entirely. `DebugSymbols`, `SourceMap`, and the
Mesen/.sym emitters had no callers outside their own tests;
`main.rs` never wrote a symbol file. Documented the intent in
`docs/future-work.md` so it comes back intentionally if needed.
- `ErrorCode::E0202` (invalid cast) and `E0403` (unreachable state):
defined, formatted, and marked `#[allow(dead_code)]` but never
emitted. W0104 now carries the unreachable-state semantics too.
- `Level::Info`: never constructed.
- `load_background` / `set_palette` statements and their
`BackgroundDecl` / `PaletteDecl` parser support: parsed and
silently dropped by IR lowering (`// TODO: implement in asset
pipeline`). Removed keywords, AST variants, parser paths, analyzer
arms, and tests. `docs/future-work.md` documents the runtime
palette/nametable design for when it comes back.
Doc cleanup:
- `docs/architecture.md` was describing files that don't exist
(`analyzer/types.rs`, `optimizer/const_fold.rs`, `codegen/regalloc.rs`,
`rom/header.rs`, `debug/symbols.rs`, …). Rewrote it to match the
real flat `mod.rs` + `tests.rs` layout and the real pipeline order.
- `docs/future-work.md` was a hybrid of open work and "recently
completed" entries that duplicated the active stubs at the top of
the file. Collapsed to just the gaps that are actually still open.
- `README.md` claimed Mesen symbol export and 210 tests; updated both.
- `docs/language-guide.md` and `spec.md` described `palette` decls,
`set_palette` / `load_background`, `debug.overlay`, and error codes
that were never emitted. Trimmed.
- Stale comments on `Statement::Play`/`StartMusic`/`StopMusic`
claimed the audio subsystem was "a no-op at codegen time".
Tests:
- Regression tests for every fix above (`lower_shift_left_with_literal
_count_uses_that_count`, `lower_shift_right_with_variable_count
_uses_runtime_variant`, `lower_divide_emits_div_op_not_load_imm
_zero`, `lower_modulo_emits_mod_op_not_load_imm_zero`,
`strength_reduce_div_by_power_of_two`, `strength_reduce_mod_by
_power_of_two`, `strength_reduce_shift_var_with_constant_amount`).
- Renamed the `program_with_sprites_and_palette` integration test
(which was exercising the now-removed `load_background`/`set_palette`)
to `program_with_inline_sprite_chr`.
`examples/sprites_and_palettes.ne` lost its `palette`/`set_palette`
usage. Nothing in the emulator test presses A, so the headless
jsnes render shouldn't move, but the golden may need regeneration
via `UPDATE_GOLDENS=1` if it does.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 02:47:37 +00:00
|
|
|
fn program_with_inline_sprite_chr() {
|
2026-04-12 00:09:47 +00:00
|
|
|
let source = r#"
|
|
|
|
|
game "M3 Assets" { mapper: NROM }
|
|
|
|
|
|
|
|
|
|
sprite Player {
|
|
|
|
|
chr: [0x3C, 0x42, 0x81, 0x81, 0x81, 0x81, 0x42, 0x3C,
|
|
|
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var px: u8 = 128
|
|
|
|
|
var py: u8 = 120
|
|
|
|
|
|
|
|
|
|
state Title {
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { px += 2 }
|
|
|
|
|
if button.left { px -= 2 }
|
|
|
|
|
draw Player at: (px, py)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start Title
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
|
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
|
|
|
#[test]
|
|
|
|
|
fn program_with_palette_compiles_and_blob_is_in_prg() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "PalTest" { mapper: NROM }
|
|
|
|
|
palette Cool {
|
|
|
|
|
colors: [0x0F, 0x01, 0x11, 0x21,
|
|
|
|
|
0x0F, 0x02, 0x12, 0x22,
|
|
|
|
|
0x0F, 0x0C, 0x1C, 0x2C,
|
|
|
|
|
0x0F, 0x0B, 0x1B, 0x2B,
|
|
|
|
|
0x0F, 0x01, 0x11, 0x21,
|
|
|
|
|
0x0F, 0x16, 0x27, 0x30,
|
|
|
|
|
0x0F, 0x14, 0x24, 0x34,
|
|
|
|
|
0x0F, 0x0B, 0x1B, 0x2B]
|
|
|
|
|
}
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile_banked(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
// The 32-byte palette blob lands verbatim inside PRG ROM.
|
|
|
|
|
// Search for a distinctive 8-byte subsequence from sub-palette 3
|
|
|
|
|
// that doesn't collide with any of the other blobs or init
|
|
|
|
|
// sequences the linker emits.
|
|
|
|
|
let needle = [0x0F, 0x16, 0x27, 0x30, 0x0F, 0x14, 0x24, 0x34];
|
|
|
|
|
let found = rom_data.windows(needle.len()).any(|w| w == needle);
|
|
|
|
|
assert!(
|
|
|
|
|
found,
|
|
|
|
|
"palette bytes should be spliced into PRG ROM verbatim"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_set_palette_queues_update_at_runtime() {
|
|
|
|
|
// A program with a `set_palette Name` statement should emit
|
|
|
|
|
// the `__ppu_update_used` marker (so the linker pulls in the
|
|
|
|
|
// NMI helper) and must contain the zero-page write sequence
|
|
|
|
|
// that stores the palette label pointer into $12/$13.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "PalRuntime" { mapper: NROM }
|
|
|
|
|
palette Swap { colors: [0x0F, 0x01, 0x11, 0x21] }
|
|
|
|
|
on frame { set_palette Swap }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile_banked(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
// $12 == ZP_PENDING_PALETTE_LO, so the code will contain
|
|
|
|
|
// `STA $12` (opcode 85 12) somewhere in PRG.
|
|
|
|
|
let sta_12 = [0x85u8, 0x12];
|
|
|
|
|
let found = rom_data.windows(sta_12.len()).any(|w| w == sta_12);
|
|
|
|
|
assert!(
|
|
|
|
|
found,
|
|
|
|
|
"set_palette codegen should emit `STA $12` (ZP_PENDING_PALETTE_LO)"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_background_compiles_and_tiles_spliced() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "BgTest" { mapper: NROM }
|
|
|
|
|
background Stage {
|
|
|
|
|
tiles: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE]
|
|
|
|
|
}
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile_banked(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
// The distinctive 5-byte prefix of the tiles blob should be in
|
|
|
|
|
// PRG verbatim (the resolver zero-pads to 960 bytes so the tail
|
|
|
|
|
// is mostly zero).
|
|
|
|
|
let needle = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
|
|
|
|
|
let found = rom_data.windows(needle.len()).any(|w| w == needle);
|
|
|
|
|
assert!(
|
|
|
|
|
found,
|
|
|
|
|
"background tile bytes should be spliced into PRG ROM verbatim"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_with_load_background_queues_update() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "BgRuntime" { mapper: NROM }
|
|
|
|
|
background Stage { tiles: [1, 2, 3] }
|
|
|
|
|
on frame { load_background Stage }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile_banked(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
// $14 == ZP_PENDING_BG_TILES_LO.
|
|
|
|
|
let sta_14 = [0x85u8, 0x14];
|
|
|
|
|
let found = rom_data.windows(sta_14.len()).any(|w| w == sta_14);
|
|
|
|
|
assert!(
|
|
|
|
|
found,
|
|
|
|
|
"load_background codegen should emit `STA $14` (ZP_PENDING_BG_TILES_LO)"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn program_without_palette_does_not_reserve_ppu_zero_page() {
|
|
|
|
|
// Regression guard: programs that don't declare palette or
|
|
|
|
|
// background should keep user vars starting at $10, same as
|
|
|
|
|
// they always did, so existing emulator goldens don't shift.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "NoPal" { mapper: NROM }
|
|
|
|
|
var x: u8 = 42
|
|
|
|
|
on frame { x += 1 }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile_banked(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
// `STA $10` (85 10) corresponds to writing to the first user
|
|
|
|
|
// var slot. Guarantees `x` is still allocated at $10.
|
|
|
|
|
let sta_10 = [0x85u8, 0x10];
|
|
|
|
|
let found = rom_data.windows(sta_10.len()).any(|w| w == sta_10);
|
|
|
|
|
assert!(
|
|
|
|
|
found,
|
|
|
|
|
"user var should still land at $10 when no palette/bg declared"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
// ── M5 Tests ──
|
|
|
|
|
|
|
|
|
|
/// Compile a source string using the mapper-aware linker.
|
|
|
|
|
fn compile_with_mapper(source: &str) -> Vec<u8> {
|
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
|
|
|
compile_banked(source)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compile a source string, running the full IR pipeline and
|
|
|
|
|
/// routing declared `bank X: prg` entries through `link_banked`
|
|
|
|
|
/// as empty switchable PRG slots. This mirrors the real CLI path.
|
|
|
|
|
fn compile_banked(source: &str) -> Vec<u8> {
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
let (program, diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(
|
|
|
|
|
diags.is_empty(),
|
|
|
|
|
"unexpected parse errors: {diags:?}\nsource:\n{source}"
|
|
|
|
|
);
|
|
|
|
|
let program = program.expect("parse should succeed");
|
|
|
|
|
|
|
|
|
|
let analysis = analyzer::analyze(&program);
|
|
|
|
|
assert!(
|
|
|
|
|
analysis.diagnostics.iter().all(|d| !d.is_error()),
|
|
|
|
|
"unexpected analysis errors: {:?}",
|
|
|
|
|
analysis.diagnostics
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut ir_program = ir::lower(&program, &analysis);
|
|
|
|
|
nescript::optimizer::optimize(&mut ir_program);
|
|
|
|
|
|
2026-04-12 10:01:44 +00:00
|
|
|
let sprites = assets::resolve_sprites(&program, Path::new("."))
|
|
|
|
|
.expect("sprite resolution should succeed");
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
let sfx = assets::resolve_sfx(&program).expect("sfx resolution should succeed");
|
|
|
|
|
let music = assets::resolve_music(&program).expect("music resolution should succeed");
|
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
|
|
|
let palettes = assets::resolve_palettes(&program);
|
|
|
|
|
let backgrounds = assets::resolve_backgrounds(&program);
|
2026-04-12 10:01:44 +00:00
|
|
|
|
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
|
|
|
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
|
|
|
|
.with_sprites(&sprites)
|
|
|
|
|
.with_audio(&sfx, &music);
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let mut instructions = codegen.generate(&ir_program);
|
|
|
|
|
nescript::codegen::peephole::optimize(&mut instructions);
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
|
|
|
|
|
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper);
|
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
|
|
|
let switchable_banks: Vec<PrgBank> = program
|
|
|
|
|
.banks
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|b| b.bank_type == BankType::Prg)
|
|
|
|
|
.map(|b| PrgBank::empty(&b.name))
|
|
|
|
|
.collect();
|
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
|
|
|
linker.link_banked_with_ppu(
|
|
|
|
|
&instructions,
|
|
|
|
|
&sprites,
|
|
|
|
|
&sfx,
|
|
|
|
|
&music,
|
|
|
|
|
&palettes,
|
|
|
|
|
&backgrounds,
|
|
|
|
|
&switchable_banks,
|
|
|
|
|
)
|
2026-04-12 10:01:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sprite_resolution_uses_tile_index() {
|
|
|
|
|
// The Player sprite has 16 unique bytes of CHR data. Because tile index 0
|
|
|
|
|
// is reserved for the built-in smiley, the compiler should place Player
|
|
|
|
|
// at tile index 1 and `draw Player` should store that tile index in OAM.
|
|
|
|
|
//
|
|
|
|
|
// We check this in two ways:
|
|
|
|
|
// 1. The CHR ROM contains Player's bytes at tile 1 (offset 16).
|
|
|
|
|
// 2. The PRG ROM contains the immediate-load sequence `A9 01 8D 01 02`
|
|
|
|
|
// (LDA #$01 ; STA $0201) — writing tile index 1 into OAM byte 1.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "SpriteTile" { mapper: NROM }
|
|
|
|
|
|
|
|
|
|
sprite Player {
|
|
|
|
|
chr: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
|
|
|
|
|
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var px: u8 = 128
|
|
|
|
|
var py: u8 = 120
|
|
|
|
|
|
|
|
|
|
state Title {
|
|
|
|
|
on frame {
|
|
|
|
|
draw Player at: (px, py)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start Title
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
|
|
|
|
|
// CHR ROM begins right after PRG ROM (16 header + 16384 PRG).
|
|
|
|
|
let chr_start = 16 + 16384;
|
|
|
|
|
// Tile 1 lives at CHR offset 16 (16 bytes per tile).
|
|
|
|
|
let tile1 = &rom_data[chr_start + 16..chr_start + 32];
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tile1,
|
|
|
|
|
&[
|
|
|
|
|
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
|
|
|
|
|
0x1E, 0x1F
|
|
|
|
|
],
|
|
|
|
|
"Player sprite CHR bytes should be placed at tile index 1",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The default smiley tile at index 0 should still be non-zero (untouched).
|
|
|
|
|
let tile0 = &rom_data[chr_start..chr_start + 16];
|
|
|
|
|
assert_ne!(
|
|
|
|
|
tile0, &[0u8; 16],
|
|
|
|
|
"tile 0 should still contain the default smiley",
|
|
|
|
|
);
|
|
|
|
|
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
// In PRG ROM, look for `LDA #$01 ; STA $0201,Y` which writes
|
|
|
|
|
// the Player's tile index (1) into the tile-index byte of the
|
|
|
|
|
// current OAM slot (the slot is computed at runtime via the
|
|
|
|
|
// OAM cursor in Y). The STA AbsoluteY opcode is $99.
|
2026-04-12 10:01:44 +00:00
|
|
|
let prg = &rom_data[16..16 + 16384];
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let pattern = [0xA9u8, 0x01, 0x99, 0x01, 0x02];
|
2026-04-12 10:01:44 +00:00
|
|
|
assert!(
|
|
|
|
|
prg.windows(pattern.len()).any(|w| w == pattern),
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
"PRG ROM should contain LDA #$01 ; STA $0201,Y for draw Player",
|
2026-04-12 10:01:44 +00:00
|
|
|
);
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
}
|
|
|
|
|
|
Implement codegen for state dispatch, functions, arrays, math, scroll
State machine dispatch:
- Assign each state a numeric index, store in ZP $03
- Main loop dispatch table: CMP + BNE + JMP trampoline pattern
(avoids branch range limits for large programs)
- on_enter/on_exit handlers generated as JSR targets
- Transition statement writes state index + JSR enter/exit handlers
Function calls:
- Function bodies emitted as labeled subroutines with RTS
- Statement::Call generates parameter passing via ZP + JSR
- Statement::Return generates RTS (with value in A if present)
- Parameter slots at ZP $04-$07
Break/continue:
- Loop stack tracks continue/break label pairs
- Break generates JMP to break_label
- Continue generates JMP to continue_label
- While and Loop push/pop the stack
Array indexing:
- LValue::ArrayIndex generates TAX + STA absolute,X
- Expr::ArrayIndex generates TAX + LDA absolute,X / ZP,X
- Compound array assignments (+=, -=, &=, |=, ^=) load-modify-store
Scroll:
- scroll(x, y) writes to PPU $2005 twice (X then Y)
Math:
- Multiply generates JSR __multiply (shift-and-add routine)
- Divide generates JSR __divide (restoring division)
- Modulo loads remainder from $03 after divide
- ShiftLeft generates ASL A, ShiftRight generates LSR A
- Math routines wired into linker
Error validations:
- E0203 for assignment to const variables
- Break/continue outside loop detection (in_loop tracking)
233 tests (8 new codegen + 2 analyzer + 2 integration), all passing.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 02:04:49 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_arrays_and_math() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "ArrayMath" { mapper: NROM }
|
|
|
|
|
var arr: u8[4] = [10, 20, 30, 40]
|
|
|
|
|
var idx: u8 = 0
|
|
|
|
|
var result: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
result = arr[idx] * 2
|
|
|
|
|
idx += 1
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile(source);
|
|
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
|
|
|
|
|
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)
Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation
210 tests total (18 new), all pre-commit checks pass.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
|
|
|
#[test]
|
|
|
|
|
fn program_with_mmc1() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "MMC1 Game" { mapper: MMC1 }
|
|
|
|
|
var px: u8 = 128
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { px += 2 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom_data = compile_with_mapper(source);
|
|
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 1, "should be MMC1 (mapper 1)");
|
|
|
|
|
}
|
2026-04-12 10:23:43 +00:00
|
|
|
|
|
|
|
|
// ── IR Codegen Tests ──
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
//
|
|
|
|
|
// These tests exercise specific end-to-end IR codegen behavior.
|
|
|
|
|
// They all use the top-level `compile()` helper now that it runs
|
|
|
|
|
// the full IR pipeline — there's no longer a separate legacy path
|
|
|
|
|
// to compare against.
|
2026-04-12 10:23:43 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_codegen_minimal_rom() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "IR Test" { mapper: NROM }
|
|
|
|
|
var x: u8 = 42
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let rom_data = compile(source);
|
2026-04-12 10:23:43 +00:00
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_codegen_full_pipeline() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "IR Full" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
var y: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { x += 1 }
|
|
|
|
|
if button.left { x -= 1 }
|
|
|
|
|
if x > 100 { x = 0 }
|
|
|
|
|
draw Smiley at: (x, y)
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let rom_data = compile(source);
|
2026-04-12 10:23:43 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
2026-04-12 10:33:58 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_codegen_multi_state_dispatch() {
|
|
|
|
|
// Exercise the IR main-loop dispatch with multiple states and a
|
|
|
|
|
// transition.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "IR States" { mapper: NROM }
|
|
|
|
|
var timer: u8 = 0
|
|
|
|
|
state Title {
|
|
|
|
|
on frame {
|
|
|
|
|
if button.start { transition Play }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state Play {
|
|
|
|
|
on frame {
|
|
|
|
|
timer += 1
|
|
|
|
|
if timer > 60 { transition Title }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start Title
|
|
|
|
|
"#;
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let rom_data = compile(source);
|
2026-04-12 10:33:58 +00:00
|
|
|
let info = rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_codegen_multi_oam() {
|
|
|
|
|
// Draw multiple sprites and verify OAM slots are allocated sequentially.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "IR MultiOAM" { mapper: NROM }
|
|
|
|
|
var a: u8 = 10
|
|
|
|
|
var b: u8 = 20
|
|
|
|
|
var c: u8 = 30
|
|
|
|
|
on frame {
|
|
|
|
|
draw One at: (a, a)
|
|
|
|
|
draw Two at: (b, b)
|
|
|
|
|
draw Three at: (c, c)
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +00:00
|
|
|
let rom_data = compile(source);
|
2026-04-12 10:33:58 +00:00
|
|
|
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
|
|
|
|
}
|
Fix three compiler bugs exposed by array-using examples
Landing bug A from the previous writeup plus two adjacent bugs
that the fix exposed. All three miscompile anything that uses a
u8[N] global with a literal initializer.
1. Array-literal globals are now actually initialized.
`lower_program` only expanded `Expr::StructLiteral` into per-
field synthetic globals — `Expr::ArrayLiteral` hit
`eval_const`, returned `None`, and the array boot-cleared to
zero. `IrGlobal` now carries an `init_array: Vec<u8>`
populated by lowering, and the IR codegen startup loop emits
one `LDA #byte; STA base+i` pair per element.
2. Local variables no longer overlap array globals.
`IrCodeGen::new` advanced `local_ram_next` past
`max_global_base + 1` — for an array at `$0300-$0303` it
placed the first handler-local at `$0301`, inside the array.
The frame handler's stores through the local then corrupted
the array mid-frame. The allocator now walks the analyzer's
`VarAllocation` list and advances past `address + size` for
every RAM global, not just the base.
3. Peephole `remove_redundant_loads` honors indexed LDAs.
The pass tracked `LDA Immediate/ZeroPage/Absolute` but let
`LDA AbsoluteX/AbsoluteY/ZeroPageX/IndirectX/IndirectY` fall
through the match, leaving the A-equivalence tracker
unchanged. A later `LDA #v` that happened to match a stale
entry from BEFORE the indexed load would then be dropped as
"already in A" — a silent miscompile that turned every
`draw Sprite at: (arr[i], arr[j])` pattern into garbage
(the second array index would be computed from `arr[i]`'s
value, reading way out of bounds). Indexed LDAs now clear
the tracker.
Regression tests:
- `src/codegen/peephole.rs`: a synthetic
`LDA #0; TAX; LDA AbsX(arr1); STA temp; LDA #0; TAX;
LDA AbsX(arr2); ...` sequence asserts both `LDA #0`s survive.
- `src/ir/tests.rs`: verifies `var xs: u8[4] = [1,2,3,4]`
populates `IrGlobal::init_array` with `[1,2,3,4]`.
- `tests/integration_test.rs`: two IR-codegen tests — one checks
the startup instructions contain `LDA #v; STA base+i` for
every element, the other compiles a handler-local var
alongside an array global and asserts no post-init stores
land inside the array.
Smoke test impact (14/14 still passing, now more visible):
- arrays_and_functions: 56 -> 104 nonBlack, now animated
- loop_break_continue: 52 -> 208 (player + 3 hazards visible)
- structs_enums_for: 52 -> 104 (player + enemy visible)
Existing examples unchanged; no remaining work for bug B
(static OAM slot allocation in loops) — that's the next PR.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 19:32:22 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_codegen_array_literal_globals_emit_per_byte_init() {
|
|
|
|
|
// Regression test: `var xs: u8[4] = [10, 20, 30, 40]` used to
|
|
|
|
|
// compile to a zero-initialized array because `eval_const`
|
|
|
|
|
// returned `None` for `Expr::ArrayLiteral` and no startup
|
|
|
|
|
// stores were emitted. The fix captures the literal values
|
|
|
|
|
// in `IrGlobal::init_array` and has the IR codegen emit one
|
|
|
|
|
// `LDA #imm; STA base+i` per byte during startup.
|
|
|
|
|
use nescript::asm::{AddressingMode, Opcode};
|
|
|
|
|
use nescript::codegen::IrCodeGen;
|
|
|
|
|
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "ArrLit" { mapper: NROM }
|
|
|
|
|
var xs: u8[4] = [10, 20, 30, 40]
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let (prog, diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
|
|
|
|
let prog = prog.unwrap();
|
|
|
|
|
let analysis = analyzer::analyze(&prog);
|
|
|
|
|
let mut ir_program = ir::lower(&prog, &analysis);
|
|
|
|
|
optimizer::optimize(&mut ir_program);
|
|
|
|
|
|
|
|
|
|
let xs_addr = analysis
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|a| a.name == "xs")
|
|
|
|
|
.expect("xs should be allocated")
|
|
|
|
|
.address;
|
|
|
|
|
|
|
|
|
|
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program);
|
|
|
|
|
let instructions = codegen.generate(&ir_program);
|
|
|
|
|
|
|
|
|
|
// For each element, look for `LDA #val` followed shortly by
|
|
|
|
|
// `STA absolute(xs_addr + i)`. We don't require them to be
|
|
|
|
|
// adjacent because the peephole passes can reshuffle, but a
|
|
|
|
|
// store of the correct value to the correct address must
|
|
|
|
|
// exist.
|
|
|
|
|
for (i, &expected) in [10u8, 20, 30, 40].iter().enumerate() {
|
|
|
|
|
let target = xs_addr + i as u16;
|
|
|
|
|
let has_store = instructions.windows(2).any(|w| {
|
|
|
|
|
matches!(w[0].mode, AddressingMode::Immediate(v) if v == expected)
|
|
|
|
|
&& w[0].opcode == Opcode::LDA
|
|
|
|
|
&& w[1].opcode == Opcode::STA
|
|
|
|
|
&& matches!(w[1].mode, AddressingMode::Absolute(a) if a == target)
|
|
|
|
|
});
|
|
|
|
|
assert!(
|
|
|
|
|
has_store,
|
|
|
|
|
"expected `LDA #{expected}; STA ${target:04X}` for xs[{i}] but did not find it"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ir_codegen_locals_do_not_overlap_array_globals() {
|
|
|
|
|
// Regression test for the local-allocator off-by-array-size
|
|
|
|
|
// bug. `IrCodeGen::new` used to start handler-local vars at
|
|
|
|
|
// `max_global_base + 1`, which for an array global at
|
|
|
|
|
// `$0300-$0303` put the first local at `$0301` — inside the
|
|
|
|
|
// array. Any store through that local then corrupted the
|
|
|
|
|
// array mid-frame. The fix advances past the global's END,
|
|
|
|
|
// not its base.
|
|
|
|
|
//
|
|
|
|
|
// We verify by asking the IR codegen what addresses it
|
|
|
|
|
// assigned. Since `var_addrs` is private, we check indirectly
|
|
|
|
|
// via emitted instructions: any `STA $030N` for N > 3 that
|
|
|
|
|
// isn't part of the startup init must be writing to a local
|
|
|
|
|
// whose address is outside the array. If the bug regressed,
|
|
|
|
|
// we'd see `STA $0302` or similar in the frame handler's
|
|
|
|
|
// computation code.
|
|
|
|
|
use nescript::asm::{AddressingMode, Opcode};
|
|
|
|
|
use nescript::codegen::IrCodeGen;
|
|
|
|
|
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "LocalVsArr" { mapper: NROM }
|
|
|
|
|
var xs: u8[4] = [11, 22, 33, 44]
|
|
|
|
|
on frame {
|
|
|
|
|
var tmp: u8 = 0
|
|
|
|
|
tmp = xs[0]
|
|
|
|
|
tmp += 1
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let (prog, diags) = nescript::parser::parse(source);
|
|
|
|
|
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
|
|
|
|
let prog = prog.unwrap();
|
|
|
|
|
let analysis = analyzer::analyze(&prog);
|
|
|
|
|
let mut ir_program = ir::lower(&prog, &analysis);
|
|
|
|
|
optimizer::optimize(&mut ir_program);
|
|
|
|
|
|
|
|
|
|
let xs_alloc = analysis
|
|
|
|
|
.var_allocations
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|a| a.name == "xs")
|
|
|
|
|
.expect("xs should be allocated");
|
|
|
|
|
let xs_base = xs_alloc.address;
|
|
|
|
|
let xs_end = xs_base + xs_alloc.size; // one past last element
|
|
|
|
|
|
|
|
|
|
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program);
|
|
|
|
|
let instructions = codegen.generate(&ir_program);
|
|
|
|
|
|
|
|
|
|
// Collect the (ordered) list of `STA absolute` targets and
|
|
|
|
|
// immediate values preceding each store. The first four
|
|
|
|
|
// stores into `[xs_base, xs_end)` should be the `LDA #imm;
|
|
|
|
|
// STA addr` init pairs — those are fine. Any STA into the
|
|
|
|
|
// array AFTER the init sequence would indicate a local var
|
|
|
|
|
// was allocated inside the array.
|
|
|
|
|
let mut init_stores_seen = 0usize;
|
|
|
|
|
for w in instructions.windows(2) {
|
|
|
|
|
if w[1].opcode != Opcode::STA {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let AddressingMode::Absolute(addr) = w[1].mode else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if addr < xs_base || addr >= xs_end {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if w[0].opcode == Opcode::LDA
|
|
|
|
|
&& matches!(w[0].mode, AddressingMode::Immediate(_))
|
|
|
|
|
&& init_stores_seen < 4
|
|
|
|
|
{
|
|
|
|
|
init_stores_seen += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
panic!(
|
|
|
|
|
"store into xs array (${addr:04X}) after init sequence — \
|
|
|
|
|
local probably overlapping with array global"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(
|
|
|
|
|
init_stores_seen, 4,
|
|
|
|
|
"expected 4 init stores for xs[0..4], found {init_stores_seen}"
|
|
|
|
|
);
|
|
|
|
|
}
|
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
|
|
|
|
|
|
|
|
// ─── End-to-end bank switching tests ───────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// These tests compile real NEScript source through the full parse
|
|
|
|
|
// → analyze → IR → codegen → linker pipeline, producing .nes ROMs
|
|
|
|
|
// that assert the bank-switching layout the README promises:
|
|
|
|
|
//
|
|
|
|
|
// * Declared `bank X: prg` slots become real 16 KB PRG banks
|
|
|
|
|
// * Fixed bank lands at the end so it maps to $C000-$FFFF
|
|
|
|
|
// * Reset vector points inside the fixed bank
|
|
|
|
|
// * Mapper-specific init code appears in the fixed bank
|
|
|
|
|
// * Every iNES header field reflects the banked layout
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_mmc1_with_two_declared_banks_produces_three_bank_rom() {
|
|
|
|
|
// MMC1 with two declared PRG banks should ship a ROM with
|
|
|
|
|
// three 16 KB PRG slots (Level1Data, Level2Data, fixed).
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "MMC1 Banked" {
|
|
|
|
|
mapper: MMC1
|
|
|
|
|
mirroring: horizontal
|
|
|
|
|
}
|
|
|
|
|
bank Level1Data: prg
|
|
|
|
|
bank Level2Data: prg
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if button.right { x += 1 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 1, "mapper number should be 1 (MMC1)");
|
|
|
|
|
assert_eq!(info.prg_banks, 3, "should have 2 switchable + 1 fixed bank");
|
|
|
|
|
assert_eq!(rom.len(), 16 + 3 * 16384 + 8192);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_uxrom_with_four_banks_produces_five_bank_rom() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "UxROM Banked" {
|
|
|
|
|
mapper: UxROM
|
|
|
|
|
mirroring: vertical
|
|
|
|
|
}
|
|
|
|
|
bank Level1: prg
|
|
|
|
|
bank Level2: prg
|
|
|
|
|
bank Level3: prg
|
|
|
|
|
bank Level4: prg
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if button.a { x += 1 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 2, "mapper number should be 2 (UxROM)");
|
|
|
|
|
assert_eq!(info.prg_banks, 5, "4 switchable + 1 fixed = 5 PRG banks");
|
|
|
|
|
assert_eq!(info.mirroring, nescript::parser::ast::Mirroring::Vertical);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_mmc3_with_three_banks_produces_four_bank_rom() {
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "MMC3 Banked" {
|
|
|
|
|
mapper: MMC3
|
|
|
|
|
mirroring: horizontal
|
|
|
|
|
}
|
|
|
|
|
bank Stage1: prg
|
|
|
|
|
bank Stage2: prg
|
|
|
|
|
bank Stage3: prg
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame {
|
|
|
|
|
if button.start { x = 1 }
|
|
|
|
|
}
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 4, "mapper number should be 4 (MMC3)");
|
|
|
|
|
assert_eq!(info.prg_banks, 4, "3 switchable + 1 fixed = 4 PRG banks");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_fixed_bank_contains_reset_vector() {
|
|
|
|
|
// The reset vector (bytes $FFFC/$FFFD in the final bank) must
|
|
|
|
|
// point into the $C000-$FFFF window — this is how the CPU
|
|
|
|
|
// boots into the fixed bank regardless of mapper.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "BankTest" { mapper: MMC1 }
|
|
|
|
|
bank Data: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
|
|
|
|
let prg_end = 16 + info.prg_banks * 16384;
|
|
|
|
|
// Last 6 bytes = NMI, RESET, IRQ vectors (little-endian).
|
|
|
|
|
let reset = u16::from_le_bytes([rom[prg_end - 4], rom[prg_end - 3]]);
|
|
|
|
|
assert!(
|
|
|
|
|
(0xC000..=0xFFFF).contains(&reset),
|
|
|
|
|
"reset vector {reset:#06X} must live in fixed-bank address window"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_fixed_bank_contains_mmc1_init_and_bank_select() {
|
|
|
|
|
// MMC1 requires a 6-way STA $8000 pattern at init (1 reset +
|
|
|
|
|
// 5 control bits) plus a 5-way STA $E000 pattern in the
|
|
|
|
|
// bank-select routine. Both must be in the fixed bank — they
|
|
|
|
|
// ship with the program regardless of whether user code
|
|
|
|
|
// calls `__bank_select` directly.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "MMC1Init" { mapper: MMC1 }
|
|
|
|
|
bank Payload: prg
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame { x += 1 }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
|
|
|
|
// The fixed bank is the last 16 KB of PRG.
|
|
|
|
|
let fixed_offset = 16 + (info.prg_banks - 1) * 16384;
|
|
|
|
|
let fixed_bank = &rom[fixed_offset..fixed_offset + 16384];
|
|
|
|
|
|
|
|
|
|
// Count STA $8000 (opcode $8D, operand little-endian $00 $80):
|
|
|
|
|
// MMC1 init writes to $8000 six times.
|
|
|
|
|
let sta_lo = [0x8Du8, 0x00, 0x80];
|
|
|
|
|
let lo_count = fixed_bank.windows(3).filter(|w| *w == sta_lo).count();
|
|
|
|
|
assert!(
|
|
|
|
|
lo_count >= 6,
|
|
|
|
|
"MMC1 fixed bank should contain >=6 STA $8000 writes (got {lo_count})"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Count STA $E000 (opcode $8D, operand $00 $E0): bank-select
|
|
|
|
|
// writes to it 5 times.
|
|
|
|
|
let sta_hi = [0x8Du8, 0x00, 0xE0];
|
|
|
|
|
let hi_count = fixed_bank.windows(3).filter(|w| *w == sta_hi).count();
|
|
|
|
|
assert!(
|
|
|
|
|
hi_count >= 5,
|
|
|
|
|
"MMC1 fixed bank should contain >=5 STA $E000 writes (got {hi_count})"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_fixed_bank_contains_uxrom_bank_table() {
|
|
|
|
|
// UxROM ships a 256-byte bank-select bus-conflict table
|
|
|
|
|
// (values 0..=255). The table must be in the fixed bank.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "UxROMInit" { mapper: UxROM }
|
|
|
|
|
bank Payload: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
let fixed_offset = 16 + (info.prg_banks - 1) * 16384;
|
|
|
|
|
let fixed = &rom[fixed_offset..fixed_offset + 16384];
|
|
|
|
|
|
|
|
|
|
// Search for a run of 0,1,2,3,...,31 — a 32-byte stretch that's
|
|
|
|
|
// distinctive enough that a random PRG byte sequence almost
|
|
|
|
|
// never contains it. The full 256-byte table starts with this
|
|
|
|
|
// prefix.
|
|
|
|
|
let mut needle: [u8; 32] = [0; 32];
|
|
|
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
|
|
|
for (i, b) in needle.iter_mut().enumerate() {
|
|
|
|
|
*b = i as u8;
|
|
|
|
|
}
|
|
|
|
|
let found = fixed.windows(needle.len()).any(|w| w == needle);
|
|
|
|
|
assert!(
|
|
|
|
|
found,
|
|
|
|
|
"UxROM fixed bank should contain the bank-select bus-conflict table"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_fixed_bank_contains_mmc3_init_writes() {
|
|
|
|
|
// MMC3 init writes two (bank-select, bank-number) pairs to
|
|
|
|
|
// ($8000, $8001) plus one $A000 mirroring write and one
|
|
|
|
|
// $E000 IRQ-disable write. We check each pattern appears.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "MMC3Init" { mapper: MMC3 }
|
|
|
|
|
bank Stage1: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
let fixed_offset = 16 + (info.prg_banks - 1) * 16384;
|
|
|
|
|
let fixed_bank = &rom[fixed_offset..fixed_offset + 16384];
|
|
|
|
|
|
|
|
|
|
let select = [0x8Du8, 0x00, 0x80];
|
|
|
|
|
let data = [0x8Du8, 0x01, 0x80];
|
|
|
|
|
let mirror = [0x8Du8, 0x00, 0xA0];
|
|
|
|
|
|
|
|
|
|
// MMC3 init writes $8000 twice, plus once per bank-select
|
|
|
|
|
// call. With no `__bank_select` invocations from user code
|
|
|
|
|
// we expect exactly 2 init writes to $8000, but the
|
|
|
|
|
// bank-select subroutine also writes $8000 once. So the
|
|
|
|
|
// minimum is 3 (2 init + 1 bank-select body).
|
|
|
|
|
let select_count = fixed_bank.windows(3).filter(|w| *w == select).count();
|
|
|
|
|
let data_count = fixed_bank.windows(3).filter(|w| *w == data).count();
|
|
|
|
|
let mirror_count = fixed_bank.windows(3).filter(|w| *w == mirror).count();
|
|
|
|
|
assert!(
|
|
|
|
|
select_count >= 3,
|
|
|
|
|
"MMC3 fixed bank should contain >=3 STA $8000 writes (got {select_count})"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
data_count >= 3,
|
|
|
|
|
"MMC3 fixed bank should contain >=3 STA $8001 writes (got {data_count})"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
mirror_count >= 1,
|
|
|
|
|
"MMC3 fixed bank should contain >=1 STA $A000 write for mirroring (got {mirror_count})"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_switchable_banks_contain_ff_padding() {
|
|
|
|
|
// Empty switchable banks should be entirely $FF-filled so no
|
|
|
|
|
// stray code accidentally lands in them. We check each
|
|
|
|
|
// switchable bank slot is 16384 bytes of $FF.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "PadCheck" { mapper: MMC1 }
|
|
|
|
|
bank A: prg
|
|
|
|
|
bank B: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
for i in 0..2 {
|
|
|
|
|
let offset = 16 + i * 16384;
|
|
|
|
|
let bank = &rom[offset..offset + 16384];
|
|
|
|
|
assert!(
|
|
|
|
|
bank.iter().all(|&b| b == 0xFF),
|
|
|
|
|
"switchable bank {i} should be all $FF padding"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_nrom_still_produces_single_bank_rom_without_declarations() {
|
|
|
|
|
// Regression: programs that don't declare banks and use NROM
|
|
|
|
|
// must still ship as a single-bank 16 KB PRG ROM (the legacy
|
|
|
|
|
// layout), unaffected by the banking pipeline.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "Plain" { mapper: NROM }
|
|
|
|
|
var x: u8 = 0
|
|
|
|
|
on frame { x += 1 }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
assert_eq!(info.prg_banks, 1);
|
|
|
|
|
assert_eq!(rom.len(), 16 + 16384 + 8192);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_chr_banks_do_not_consume_prg_slots() {
|
|
|
|
|
// A `bank X: chr` declaration reserves CHR space, not PRG.
|
|
|
|
|
// The linker currently keeps CHR at a single 8 KB slot, so
|
|
|
|
|
// declaring a CHR bank should NOT add a PRG slot.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "CHRBank" { mapper: MMC1 }
|
|
|
|
|
bank TileBank: chr
|
|
|
|
|
bank PrgBank: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
// 1 PRG bank declared + 1 fixed = 2 total; TileBank:chr should
|
|
|
|
|
// NOT bump the PRG count.
|
|
|
|
|
assert_eq!(info.prg_banks, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_mmc1_banked_example_compiles_successfully() {
|
|
|
|
|
// The examples/mmc1_banked.ne file is the canonical example
|
|
|
|
|
// the README points at. It must compile cleanly through the
|
|
|
|
|
// full pipeline and produce a valid multi-bank ROM.
|
|
|
|
|
let source = include_str!("../examples/mmc1_banked.ne");
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
|
|
|
|
assert_eq!(info.mapper, 1, "mmc1_banked example should ship as MMC1");
|
|
|
|
|
assert!(
|
|
|
|
|
info.prg_banks >= 2,
|
|
|
|
|
"mmc1_banked example should ship with at least 2 PRG banks (got {})",
|
|
|
|
|
info.prg_banks
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_large_bank_count_still_produces_valid_rom() {
|
|
|
|
|
// Stress test: 7 switchable banks (8 total) on UxROM. This
|
|
|
|
|
// exercises the ROM builder's multi-bank concatenation with
|
|
|
|
|
// a non-trivial bank count and ensures nothing in the linker
|
|
|
|
|
// pipeline hard-codes a bank limit.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "LotsOfBanks" { mapper: UxROM }
|
|
|
|
|
bank A: prg
|
|
|
|
|
bank B: prg
|
|
|
|
|
bank C: prg
|
|
|
|
|
bank D: prg
|
|
|
|
|
bank E: prg
|
|
|
|
|
bank F: prg
|
|
|
|
|
bank G: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
assert_eq!(info.prg_banks, 8, "7 switchable + 1 fixed = 8 PRG banks");
|
|
|
|
|
assert_eq!(rom.len(), 16 + 8 * 16384 + 8192);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_rom_ines_header_mapper_bits_encoded_correctly() {
|
|
|
|
|
// Sanity check: the iNES header's mapper number field is split
|
|
|
|
|
// across byte 6 (low nibble) and byte 7 (high nibble). For
|
|
|
|
|
// mapper 1 (MMC1), byte 6 should have $10 in its high nibble
|
|
|
|
|
// and byte 7 should have $00 in its high nibble.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "HeaderCheck" { mapper: MMC1 }
|
|
|
|
|
bank Foo: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let byte6_high_nibble = rom[6] & 0xF0;
|
|
|
|
|
let byte7_high_nibble = rom[7] & 0xF0;
|
|
|
|
|
assert_eq!(byte6_high_nibble, 0x10, "MMC1 low mapper nibble in byte 6");
|
|
|
|
|
assert_eq!(byte7_high_nibble, 0x00, "MMC1 high mapper nibble in byte 7");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_all_three_mappers_have_correct_vectors() {
|
|
|
|
|
// For each banked mapper, verify all three vectors (NMI, RESET,
|
|
|
|
|
// IRQ) live inside the fixed bank address window.
|
|
|
|
|
for mapper_kw in ["MMC1", "UxROM", "MMC3"] {
|
|
|
|
|
let source = format!(
|
|
|
|
|
r#"
|
|
|
|
|
game "VecCheck" {{ mapper: {mapper_kw} }}
|
|
|
|
|
bank One: prg
|
|
|
|
|
on frame {{ wait_frame }}
|
|
|
|
|
start Main
|
|
|
|
|
"#
|
|
|
|
|
);
|
|
|
|
|
let rom = compile_banked(&source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
let prg_end = 16 + info.prg_banks * 16384;
|
|
|
|
|
let nmi = u16::from_le_bytes([rom[prg_end - 6], rom[prg_end - 5]]);
|
|
|
|
|
let reset = u16::from_le_bytes([rom[prg_end - 4], rom[prg_end - 3]]);
|
|
|
|
|
let irq = u16::from_le_bytes([rom[prg_end - 2], rom[prg_end - 1]]);
|
|
|
|
|
for (name, v) in [("NMI", nmi), ("RESET", reset), ("IRQ", irq)] {
|
|
|
|
|
assert!(
|
|
|
|
|
(0xC000..=0xFFFF).contains(&v),
|
|
|
|
|
"{mapper_kw} {name} vector {v:#06X} should be in fixed-bank window"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_bank_declarations_dont_affect_nrom_prg_size() {
|
|
|
|
|
// Even though the linker REJECTS switchable banks for NROM,
|
|
|
|
|
// the compiler only passes banks through when they're in the
|
|
|
|
|
// `program.banks` list — for NROM sources without declarations
|
|
|
|
|
// nothing is passed, so the NROM path is unchanged. Just
|
|
|
|
|
// double-check here that a plain NROM ROM is still 1 bank.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "JustNROM" { mapper: NROM }
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
assert_eq!(info.prg_banks, 1);
|
|
|
|
|
assert_eq!(info.mapper, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn e2e_banked_chr_rom_is_preserved() {
|
|
|
|
|
// CHR ROM should still contain the default smiley sprite at
|
|
|
|
|
// tile 0 regardless of how many PRG banks the ROM has.
|
|
|
|
|
let source = r#"
|
|
|
|
|
game "CHRCheck" { mapper: MMC1 }
|
|
|
|
|
bank One: prg
|
|
|
|
|
bank Two: prg
|
|
|
|
|
on frame { wait_frame }
|
|
|
|
|
start Main
|
|
|
|
|
"#;
|
|
|
|
|
let rom = compile_banked(source);
|
|
|
|
|
let info = rom::validate_ines(&rom).unwrap();
|
|
|
|
|
let chr_start = 16 + info.prg_banks * 16384;
|
|
|
|
|
// Default smiley is non-zero in its first 16 bytes.
|
|
|
|
|
assert_ne!(&rom[chr_start..chr_start + 16], &[0u8; 16]);
|
|
|
|
|
}
|