1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 01:37:45 +00:00
nescript/src/runtime/tests.rs

727 lines
24 KiB
Rust
Raw Normal View History

Implement NEScript compiler Milestone 1 ("Hello Sprite") Complete implementation of the NEScript compiler pipeline for M1: - Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators - Parser: recursive descent with Pratt expression parsing (M1 subset) - Analyzer: symbol resolution, type checking, memory allocation - 6502 Assembler: full opcode encoding table (~150 valid combinations) - Code Generator: AST → 6502 instructions (direct, no IR for M1) - Runtime: NES hardware init, NMI handler, controller read, OAM DMA - Linker: NROM layout, vector table, palette loading, CHR data - ROM Builder: iNES header generation, PRG/CHR padding - CLI: `build` and `check` subcommands via clap 143 tests across all modules: - 22 lexer tests (literals, keywords, operators, error recovery) - 18 parser tests (expressions, statements, game structure, errors) - 7 analyzer tests (symbol resolution, memory allocation, transitions) - 30 assembler tests (every addressing mode, label resolution) - 7 codegen tests (var init, arithmetic, buttons, draw, comparisons) - 11 runtime tests (init sequence, NMI handler, controller read) - 10 ROM builder tests (iNES format, mirroring, banking, validation) - 5 linker tests (vector table, CHR data, palette loading) - 7 integration tests (end-to-end compilation, error detection) CI: GitHub Actions for check, fmt, clippy, test Pre-commit: script for local fmt + clippy + test validation https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00
use super::*;
use crate::asm;
use crate::asm::{AddressingMode as AM, Opcode::*};
#[test]
fn init_disables_irq() {
let init = gen_init();
assert_eq!(init[0].opcode, SEI);
}
#[test]
fn init_sets_stack_pointer() {
let init = gen_init();
// LDX #$FF, TXS
let has_ldx = init
.iter()
.any(|i| i.opcode == LDX && i.mode == AM::Immediate(0xFF));
let has_txs = init.iter().any(|i| i.opcode == TXS);
assert!(has_ldx, "should load $FF into X");
assert!(has_txs, "should transfer X to stack pointer");
}
#[test]
fn init_disables_ppu() {
let init = gen_init();
// Should write 0 to $2000 and $2001
let writes_ppu_ctrl = init
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2000));
let writes_ppu_mask = init
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x2001));
assert!(writes_ppu_ctrl, "should disable PPU control");
assert!(writes_ppu_mask, "should disable PPU mask");
}
#[test]
fn init_enables_nmi_at_end() {
let init = gen_init();
// Last STA $2000 should enable NMI (bit 7 set = 0x80)
let nmi_writes: Vec<_> = init
.iter()
.enumerate()
.filter(|(_, i)| i.opcode == STA && i.mode == AM::Absolute(0x2000))
.collect();
assert!(
nmi_writes.len() >= 2,
"should write to PPU_CTRL at least twice"
);
// The last write should be preceded by LDA #$80
let last_write_idx = nmi_writes.last().unwrap().0;
assert!(last_write_idx > 0);
assert_eq!(init[last_write_idx - 1].opcode, LDA);
assert_eq!(init[last_write_idx - 1].mode, AM::Immediate(0x80));
}
#[test]
fn init_assembles_without_error() {
let init = gen_init();
let result = asm::assemble(&init, 0x8000);
// Should produce non-empty output
assert!(!result.bytes.is_empty(), "init should produce bytes");
// Should be under 200 bytes (the plan estimates ~80)
assert!(
result.bytes.len() < 200,
"init is {} bytes, expected < 200",
result.bytes.len()
);
}
#[test]
fn nmi_saves_and_restores_registers() {
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
let nmi = gen_nmi(false, false, false);
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
// First three instructions should push A, X, Y
assert_eq!(nmi[0].opcode, PHA);
assert_eq!(nmi[1].opcode, TXA);
assert_eq!(nmi[2].opcode, PHA);
assert_eq!(nmi[3].opcode, TYA);
assert_eq!(nmi[4].opcode, PHA);
// Last instructions should restore and RTI
let len = nmi.len();
assert_eq!(nmi[len - 1].opcode, RTI);
assert_eq!(nmi[len - 2].opcode, PLA);
}
#[test]
fn nmi_triggers_oam_dma() {
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
let nmi = gen_nmi(false, false, false);
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 has_dma = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4014));
assert!(has_dma, "NMI should trigger OAM DMA");
}
#[test]
fn nmi_reads_controller() {
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
let nmi = gen_nmi(false, false, false);
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
// Should write strobe to $4016
let has_strobe = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4016));
assert!(has_strobe, "NMI should strobe controller");
}
#[test]
fn nmi_sets_frame_flag() {
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
let nmi = gen_nmi(false, false, false);
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 has_flag = nmi
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_FRAME_FLAG));
assert!(has_flag, "NMI should set frame-ready flag");
}
#[test]
fn nmi_assembles_without_error() {
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
let nmi = gen_nmi(false, false, false);
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 result = asm::assemble(&nmi, 0xF000);
assert!(!result.bytes.is_empty());
assert!(
result.bytes.len() < 150,
"NMI handler is {} bytes, expected < 150",
result.bytes.len()
);
}
debug: add symbol export, source maps, bounds checks, overrun counter Implements four items from docs/future-work.md's "Debug instrumentation" section so debugging on real ROMs is no longer a guessing game: 1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs` renders that plus the analyzer's var allocations into a Mesen- compatible label listing (function entry points get `P:` entries at PRG-relative offsets; user vars get `R:` entries). 2. Source maps via `--source-map <path>`. IR lowering now emits a `SourceLoc(span)` op before every statement; the codegen turns each one into a `__src_<N>` label-definition pseudo-op and records the span in a side table. Source-marker emission is opt-in (`with_source_map(true)`) because labels become peephole block boundaries — leaving the markers off preserves byte-identical release ROMs. 3. Array bounds checking under `--debug`. Every `ArrayLoad` / `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:` guard, and the codegen emits one shared `__debug_halt` trap at the end of the fixed bank (writes $BC to the debug port then wedges in a tight `JMP $`). Release builds skip the whole thing. 4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of the handler and increments a counter at `$07FF` (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning the main loop didn't reach `wait_frame` before the next vblank. User code can read the counter via `peek(0x07FF)`. This is the abbreviated form the future-work doc suggested: a bump-a-counter hook rather than a full cycle-budget tracker, which would need a new builtin. The codegen emits a `__debug_mode` marker label in debug mode so the linker can select the overrun-aware NMI variant. Release ROMs for every committed example are byte-identical before and after this change (verified with `git diff examples/` after a full rebuild). All 512 lib tests and 71 integration tests pass; `cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean. https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
#[test]
fn nmi_debug_mode_bumps_overrun_counter() {
// With `debug_mode = true`, the NMI handler must include an
// `INC $07FF` (the frame-overrun counter at
// `DEBUG_FRAME_OVERRUN_ADDR`) guarded by a BEQ that skips the
// bump when the frame flag was clear. Without `debug_mode`,
// neither the `INC` nor the guard label appear so release
// builds keep the top byte of RAM free for user allocation.
let nmi = gen_nmi(false, false, true);
let has_inc = nmi.iter().any(|i| {
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
});
assert!(
has_inc,
"debug-mode NMI should INC the overrun counter at $07FF"
);
let release_nmi = gen_nmi(false, false, false);
let has_inc_release = release_nmi.iter().any(|i| {
i.opcode == INC && matches!(i.mode, AM::Absolute(a) if a == DEBUG_FRAME_OVERRUN_ADDR)
});
assert!(
!has_inc_release,
"release NMI must not touch the debug overrun slot"
);
}
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 irq_handler_is_just_rti() {
let irq = gen_irq();
assert_eq!(irq.len(), 1);
assert_eq!(irq[0].opcode, RTI);
}
#[test]
fn multiply_routine_assembles() {
let mul = gen_multiply();
// Should have a reasonable number of instructions
assert!(
mul.len() > 5,
"multiply routine too short: {} instructions",
mul.len()
);
let result = asm::assemble(&mul, 0x8000);
assert!(
!result.bytes.is_empty(),
"multiply routine should produce bytes"
);
// Should be under 100 bytes (compact 6502 routine)
assert!(
result.bytes.len() < 100,
"multiply routine is {} bytes, expected < 100",
result.bytes.len()
);
// Should contain the __multiply label
assert!(
result.labels.contains_key("__multiply"),
"should define __multiply label"
);
// Should end with RTS
assert_eq!(
mul.last().unwrap().opcode,
RTS,
"multiply routine should end with RTS"
);
}
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
// ── Audio driver tests ──
#[test]
fn audio_tick_defines_required_labels() {
let tick = gen_audio_tick();
// The IR codegen JSRs into `__audio_tick`; that's the entry.
let has_entry = tick
.iter()
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_tick"));
assert!(has_entry, "audio tick must define __audio_tick entry label");
// The tick references `__period_table` via SymbolLo/SymbolHi —
// the period table itself is linked in separately.
let refs_period = tick.iter().any(|i| {
matches!(&i.mode, AM::SymbolLo(n) if n == "__period_table")
|| matches!(&i.mode, AM::SymbolHi(n) if n == "__period_table")
});
assert!(
refs_period,
"audio tick must reference the __period_table label"
);
}
#[test]
fn audio_tick_ends_with_rts() {
let tick = gen_audio_tick();
assert_eq!(
tick.last().unwrap().opcode,
RTS,
"audio tick must return to caller"
);
}
#[test]
fn audio_tick_reads_sfx_envelope_via_indirect_y() {
// The sfx branch walks the envelope via (ZP_SFX_PTR_LO),Y with
// Y=0 — each NMI reads one byte through the pointer and writes
// it to $4000. Verify the indirect-indexed load is present.
let tick = gen_audio_tick();
let has_load = tick
.iter()
.any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO));
assert!(
has_load,
"audio tick must read envelope via (ZP_SFX_PTR_LO),Y"
);
}
#[test]
fn audio_tick_writes_pulse1_envelope_register() {
// After reading the envelope byte the tick writes it to $4000.
let tick = gen_audio_tick();
let has_store = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4000));
assert!(has_store, "audio tick must write pulse-1 envelope to $4000");
}
#[test]
fn audio_tick_mutes_pulse2_on_non_looping_end_of_track() {
// When a non-looping track hits the (0xFF, 0xFF) sentinel, the
// tick writes $30 to $4004 and clears ZP_MUSIC_STATE. We verify
// the mute path exists by checking both writes exist somewhere
// in the tick body.
let tick = gen_audio_tick();
let has_mute = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004));
assert!(has_mute, "audio tick must mute pulse-2 on end-of-track");
let has_state_clear = tick
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_MUSIC_STATE));
assert!(
has_state_clear,
"audio tick must clear ZP_MUSIC_STATE on stop"
);
}
#[test]
fn audio_tick_assembles_without_error() {
// Splice the period table into the same assembly pass so the
// tick's SymbolLo/SymbolHi references resolve. The tick also
// uses label-relative branches internally which need to fit
// within ±127 bytes — if the body grows past that the branches
// will panic at assemble time.
let mut combined = gen_audio_tick();
combined.extend(gen_period_table());
let result = asm::assemble(&combined, 0xC000);
assert!(
!result.bytes.is_empty(),
"audio tick + period table should assemble"
);
assert!(
result.labels.contains_key("__audio_tick"),
"audio tick entry label should be exported"
);
assert!(
result.labels.contains_key("__period_table"),
"period table label should be exported"
);
}
#[test]
fn period_table_has_60_entries_of_2_bytes() {
// The table covers C1..B5 inclusive = 60 semitones, 2 bytes
// each for period_lo and period_hi. Total = 120 data bytes
// plus the leading label pseudo-instruction.
let table = gen_period_table();
// Count the raw bytes in the single `Bytes` block.
let total: usize = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.len()),
_ => None,
})
.sum();
assert_eq!(total, 120, "period table should be 60 entries × 2 bytes");
}
#[test]
fn period_table_high_bytes_include_length_counter_bit() {
// Every period_hi byte must have bit 3 set ($08) so the length
// counter holds the note indefinitely. Without that bit, pulse
// 2 would silence after a few frames.
let table = gen_period_table();
let bytes: Vec<u8> = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.clone()),
_ => None,
})
.flatten()
.collect();
for (i, chunk) in bytes.chunks(2).enumerate() {
let hi = chunk[1];
assert!(
hi & 0x08 != 0,
"period table entry {i} high byte ${hi:02X} missing length-counter bit"
);
}
}
#[test]
fn period_table_a4_matches_440hz() {
// Entry for A4 should produce ~253 period. Sanity check the
// rounding: CPU/(16*440)-1 ≈ 253.12.
let table = gen_period_table();
let bytes: Vec<u8> = table
.iter()
.filter_map(|i| match &i.mode {
AM::Bytes(v) => Some(v.clone()),
_ => None,
})
.flatten()
.collect();
// A4 is semitone 69 in MIDI. C1 is MIDI 24 (entry 0 in the
// table). A4 = entry 69 - 24 = 45. Each entry is 2 bytes.
let lo = bytes[45 * 2];
let hi = bytes[45 * 2 + 1] & 0x07; // strip length-counter bit
let period = u16::from_le_bytes([lo, hi]);
// Expect period ≈ 253 (±1 for rounding).
assert!(
(252..=254).contains(&period),
"A4 period {period} should be ~253"
);
}
#[test]
fn gen_data_block_emits_label_and_bytes() {
let block = gen_data_block("__sfx_test", vec![0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(block.len(), 2);
assert!(matches!(&block[0].mode, AM::Label(n) if n == "__sfx_test"));
match &block[1].mode {
AM::Bytes(v) => assert_eq!(v, &[0xDE, 0xAD, 0xBE, 0xEF]),
other => panic!("expected Bytes, got {other:?}"),
}
}
#[test]
fn data_block_assembles_verbatim() {
// A labelled data block must emit exactly the payload bytes
// (no opcode prefix) and register the label at the payload's
// address. Verifies the `NOP+Bytes` pseudo doesn't accidentally
// get wrapped with an instruction byte.
let block = gen_data_block("__test", vec![0x11, 0x22, 0x33]);
let result = asm::assemble(&block, 0x8000);
assert_eq!(result.bytes, vec![0x11, 0x22, 0x33]);
assert_eq!(result.labels.get("__test").copied(), Some(0x8000));
}
#[test]
fn divide_routine_assembles() {
let div = gen_divide();
// Should have a reasonable number of instructions
assert!(
div.len() > 5,
"divide routine too short: {} instructions",
div.len()
);
let result = asm::assemble(&div, 0x8000);
assert!(
!result.bytes.is_empty(),
"divide routine should produce bytes"
);
// Should be under 100 bytes (compact 6502 routine)
assert!(
result.bytes.len() < 100,
"divide routine is {} bytes, expected < 100",
result.bytes.len()
);
// Should contain the __divide label
assert!(
result.labels.contains_key("__divide"),
"should define __divide label"
);
// Should end with RTS
assert_eq!(
div.last().unwrap().opcode,
RTS,
"divide routine should end with RTS"
);
}
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
// ─── Bank switching ────────────────────────────────────────────────
#[test]
fn mapper_init_nrom_is_empty() {
// NROM has no banks and nothing to configure at reset — the
// generator must return an empty Vec so the linker doesn't
// pay any ROM cost for unused mapper config.
let init = gen_mapper_init(Mapper::NROM, Mirroring::Horizontal, 1);
assert!(
init.is_empty(),
"NROM mapper init should be empty, got {} instructions",
init.len()
);
}
#[test]
fn mapper_init_mmc1_pulses_reset_and_writes_control() {
// MMC1 init must: (1) pulse bit 7 of any $8000-range write to
// reset the shift register, then (2) serialize a 5-bit control
// value into the same $8000 register window. We verify:
// * there's at least one STA to $8000 preceded by LDA #$80
// * there are exactly 6 writes to $8000 total (1 reset + 5 bits)
let init = gen_mapper_init(Mapper::MMC1, Mirroring::Horizontal, 4);
let writes_8000: Vec<_> = init
.iter()
.enumerate()
.filter(|(_, i)| i.opcode == STA && i.mode == AM::Absolute(0x8000))
.collect();
assert_eq!(
writes_8000.len(),
6,
"MMC1 init should write to $8000 six times (1 reset + 5 control bits), got {}",
writes_8000.len()
);
// The reset write comes first and must be preceded by LDA #$80.
let first_idx = writes_8000[0].0;
assert!(first_idx > 0);
assert_eq!(init[first_idx - 1].opcode, LDA);
assert_eq!(init[first_idx - 1].mode, AM::Immediate(0x80));
}
/// Find the first LDA immediate operand appearing after the MMC1
/// reset-pulse (`LDA #$80`) inside an MMC1 init sequence. Used by
/// [`mapper_init_mmc1_horizontal_vs_vertical_control_bits`] to
/// inspect the first serialized control-register bit.
fn mmc1_first_control_bit(init: &[Instruction]) -> Option<u8> {
let mut saw_reset = false;
for inst in init {
if !saw_reset {
if inst.opcode == LDA && inst.mode == AM::Immediate(0x80) {
saw_reset = true;
}
continue;
}
if inst.opcode == LDA {
if let AM::Immediate(v) = inst.mode {
return Some(v);
}
}
}
None
}
#[test]
fn mapper_init_mmc1_horizontal_vs_vertical_control_bits() {
// The control register's bits 0-1 encode mirroring. Our layout
// uses $0F for horizontal (0b01111) and $0E for vertical
// (0b01110). The first bit sent (LDA #0 or #1) differs between
// the two — horizontal bit 0 = 1, vertical bit 0 = 0.
let h = gen_mapper_init(Mapper::MMC1, Mirroring::Horizontal, 2);
let v = gen_mapper_init(Mapper::MMC1, Mirroring::Vertical, 2);
assert_eq!(
mmc1_first_control_bit(&h),
Some(1),
"horizontal mirror bit 0"
);
assert_eq!(mmc1_first_control_bit(&v), Some(0), "vertical mirror bit 0");
}
#[test]
fn mapper_init_uxrom_emits_label_and_nothing_else() {
// UxROM powers up with bank 0 at $8000 and the last bank fixed
// at $C000 — exactly what the NEScript runtime expects. All we
// need is a marker label so debuggers can find the (empty)
// init span.
let init = gen_mapper_init(Mapper::UxROM, Mirroring::Horizontal, 3);
assert_eq!(init.len(), 1);
assert!(
matches!(&init[0].mode, AM::Label(n) if n == "__uxrom_init"),
"UxROM init should emit just the marker label",
);
}
#[test]
fn mapper_init_mmc3_configures_prg_and_mirroring() {
// MMC3 init writes:
// $8000 = 6 (select PRG-0 register)
// $8001 = 0 (bank 0 at $8000)
// $8000 = 7 (select PRG-1 register)
// $8001 = 1 (bank 1 at $A000)
// $A000 = mirroring bit
// $E000 = 0 (disable IRQ)
let init = gen_mapper_init(Mapper::MMC3, Mirroring::Vertical, 4);
let count_writes = |addr: u16| -> usize {
init.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(addr))
.count()
};
assert_eq!(count_writes(0x8000), 2, "MMC3 should write $8000 twice");
assert_eq!(count_writes(0x8001), 2, "MMC3 should write $8001 twice");
assert_eq!(
count_writes(0xA000),
1,
"MMC3 should write $A000 for mirroring"
);
assert_eq!(
count_writes(0xE000),
1,
"MMC3 should clear $E000 to disable IRQ"
);
}
#[test]
fn mapper_init_assembles_for_every_banked_mapper() {
// Sanity check: every mapper's init sequence should pass the
// assembler without unresolved labels. We splice in a dummy
// reset label to prevent branch-range issues.
for m in [Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
let init = gen_mapper_init(m, Mirroring::Horizontal, 2);
let result = asm::assemble(&init, 0xC000);
// Either empty (UxROM is basically zero-cost) or a short
// init stub — all fit comfortably in < 100 bytes.
assert!(
result.bytes.len() < 100,
"mapper {m:?} init is {} bytes, expected < 100",
result.bytes.len()
);
}
}
#[test]
fn bank_select_nrom_is_a_plain_rts() {
// The NROM bank-select stub exists so user code can
// unconditionally call `__bank_select` regardless of mapper.
// Its body must RTS immediately — no register writes.
let sel = gen_bank_select(Mapper::NROM);
assert!(matches!(&sel[0].mode, AM::Label(n) if n == "__bank_select"));
assert_eq!(sel.last().unwrap().opcode, RTS);
// Must not write to any mapper register.
let writes_mapper = sel.iter().any(|i| {
i.opcode == STA
&& matches!(
&i.mode,
AM::Absolute(a) if (0x8000..=0xFFFF).contains(a)
)
});
assert!(!writes_mapper, "NROM bank-select must not write to $8000+");
}
#[test]
fn bank_select_mmc1_serializes_five_bits_to_e000() {
// MMC1 bank-select serializes the 5 LSBs of A into the $E000
// register. We check: exactly 5 STA $E000 instructions, and
// they're interleaved with LSR A shifts between them (4 LSRs
// total — one between each pair of writes).
let sel = gen_bank_select(Mapper::MMC1);
let writes_e000 = sel
.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0xE000))
.count();
assert_eq!(writes_e000, 5, "MMC1 should write $E000 five times");
let lsrs = sel
.iter()
.filter(|i| i.opcode == LSR && i.mode == AM::Accumulator)
.count();
assert_eq!(lsrs, 4, "MMC1 should shift A four times between bit writes");
assert_eq!(sel.last().unwrap().opcode, RTS);
}
#[test]
fn bank_select_uxrom_writes_fff0() {
// UxROM bank-select writes A to $FFF0, which lives in the
// fixed bank's bus-conflict-safe table.
let sel = gen_bank_select(Mapper::UxROM);
let has_write = sel
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0xFFF0));
assert!(has_write, "UxROM bank-select must write to $FFF0");
assert_eq!(sel.last().unwrap().opcode, RTS);
}
#[test]
fn bank_select_mmc3_writes_8000_and_8001() {
// MMC3 bank-select writes 6 to $8000, then writes A to $8001.
// We check both writes happen exactly once.
let sel = gen_bank_select(Mapper::MMC3);
let writes_8000 = sel
.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x8000))
.count();
let writes_8001 = sel
.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x8001))
.count();
assert_eq!(writes_8000, 1, "MMC3 should write $8000 once");
assert_eq!(writes_8001, 1, "MMC3 should write $8001 once");
assert_eq!(sel.last().unwrap().opcode, RTS);
}
#[test]
fn bank_select_stashes_bank_number_in_zp() {
// Every bank-select routine (including NROM) saves A into
// ZP_BANK_CURRENT so trampolines can restore the previous
// bank later.
for m in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
let sel = gen_bank_select(m);
let has_stash = sel
.iter()
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(ZP_BANK_CURRENT));
assert!(
has_stash,
"mapper {m:?} bank-select must stash A into ZP_BANK_CURRENT"
);
}
}
#[test]
fn bank_select_assembles_for_every_mapper() {
for m in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
let sel = gen_bank_select(m);
let result = asm::assemble(&sel, 0xC000);
assert!(
!result.bytes.is_empty(),
"mapper {m:?} bank-select produced no bytes"
);
assert!(
result.labels.contains_key("__bank_select"),
"mapper {m:?} bank-select must export __bank_select"
);
}
}
#[test]
fn trampoline_switches_target_then_restores_fixed() {
// A trampoline must JSR `__bank_select` twice: once with the
// target bank's index, once with the fixed bank's index. The
// two LDA immediates in the stub should match those two bank
// numbers in order.
let t = gen_bank_trampoline("Level1", "__bank_Level1_entry", 0, 3);
// First instruction is the trampoline label.
assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_Level1"));
// Extract the sequence of immediate loads.
let imms: Vec<u8> = t
.iter()
.filter_map(|i| {
if i.opcode == LDA {
if let AM::Immediate(v) = i.mode {
return Some(v);
}
}
None
})
.collect();
assert_eq!(imms, vec![0, 3], "trampoline should load target then fixed");
// And two JSRs to __bank_select, plus one JSR to the entry.
let jsrs: Vec<&str> = t
.iter()
.filter_map(|i| {
if i.opcode == JSR {
if let AM::Label(n) = &i.mode {
return Some(n.as_str());
}
}
None
})
.collect();
assert_eq!(
jsrs,
vec!["__bank_select", "__bank_Level1_entry", "__bank_select"],
"trampoline JSRs must dispatch in the correct order"
);
// Final instruction returns to caller.
assert_eq!(t.last().unwrap().opcode, RTS);
}
#[test]
fn trampoline_label_derives_from_bank_name() {
// Trampoline labels are consistently named `__tramp_<bank>` so
// codegen can reference them without knowing bank indices.
let t = gen_bank_trampoline("MusicData", "__music_entry", 1, 3);
assert!(matches!(&t[0].mode, AM::Label(n) if n == "__tramp_MusicData"));
}
#[test]
fn uxrom_bank_table_is_256_bytes_of_sequential_values() {
// The bus-conflict table must contain bytes 0..=255 in order
// so that `STA __bank_select_table,X` where X = desired bank
// number produces a matching ROM byte.
let table = gen_uxrom_bank_table();
assert_eq!(table.len(), 2);
assert!(matches!(&table[0].mode, AM::Label(n) if n == "__bank_select_table"));
match &table[1].mode {
AM::Bytes(v) => {
assert_eq!(v.len(), 256);
for (i, &b) in v.iter().enumerate() {
assert_eq!(b as usize, i, "table byte at {i} should equal {i}");
}
}
other => panic!("expected Bytes, got {other:?}"),
}
}