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() {
|
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() {
|
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() {
|
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() {
|
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() {
|
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()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
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 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|