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() {
|
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
|
// 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() {
|
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
|
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() {
|
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
|
// 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() {
|
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
|
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() {
|
|
|
|
|
|
let nmi = gen_nmi();
|
|
|
|
|
|
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()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|