1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-11 18:20:47 +00:00
nescript/src/linker/tests.rs

593 lines
23 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::{AddressingMode as AM, Instruction, Opcode::*};
banks: implement multi-bank PRG layout and bank-switching runtime Prior to this commit the linker always shipped a single 16 KB PRG bank regardless of the declared mapper, so the README's MMC1/UxROM/ MMC3 support was aspirational. This commit gives the three banked mappers a real multi-bank ROM layout: * RomBuilder.set_prg_banks() writes any number of 16 KB banks back-to-back so the iNES header reflects the true PRG size. * Linker.link_banked() places switchable banks first, fixed bank last, so the fixed bank maps to $C000-$FFFF (the address window where vectors and the runtime live). * runtime::gen_mapper_init() emits reset-time mapper config: MMC1 serial-writes a control-register value that pins the last bank at $C000 with the correct mirroring, UxROM relies on the power-on default, MMC3 writes the $8000/$8001/$A000/$E000 registers to get a known PRG and mirroring state. * runtime::gen_bank_select() is a mapper-specific subroutine (callable with the target bank in A) that maps any physical bank to $8000-$BFFF. * runtime::gen_bank_trampoline() generates a cross-bank call stub in the fixed bank that saves the caller's bank, switches, JSRs the target, and restores the fixed bank. * The CLI and integration helper thread declared `bank X: prg` declarations through to the linker so MMC1/UxROM/MMC3 programs actually produce multi-bank ROMs. Coverage: * Runtime unit tests (18 new): mapper init patterns for every supported mapper, bank-select signatures, trampoline dispatch order, UxROM bus-conflict table contents. * RomBuilder tests (6 new): multi-bank layout, padding, byte-level fidelity, per-bank size validation, legacy single-bank fallback. * Linker tests (13 new): multi-bank ROM sizes across MMC1/ UxROM/MMC3, fixed-bank placement, switchable-bank payload fidelity, bank-select subroutine detection, NROM rejection of switchable banks. * Integration e2e tests (16 new): compile real .ne sources through the full pipeline and assert on iNES headers, mapper init signatures in the fixed bank, vector locations, and a regression check against `examples/mmc1_banked.ne`. Total: 474 tests pass under `cargo test` with `RUSTFLAGS="-D warnings"`. https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
use crate::parser::ast::{Mapper, Mirroring};
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 crate::rom;
#[test]
fn link_produces_valid_ines() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![
Instruction::new(NOP, AM::Label("__main_loop".into())),
Instruction::implied(NOP),
Instruction::new(JMP, AM::Label("__main_loop".into())),
];
let rom_data = linker.link(&user_code);
let info = rom::validate_ines(&rom_data).unwrap();
assert_eq!(info.prg_banks, 1);
assert_eq!(info.chr_banks, 1);
assert_eq!(info.mapper, 0);
}
#[test]
fn link_has_correct_vector_table() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let rom_data = linker.link(&user_code);
// Vector table is at the last 6 bytes of PRG ROM
// PRG starts at offset 16 in the .nes file
let prg_end = 16 + 16384;
let vector_start = prg_end - 6;
// NMI vector (2 bytes, little-endian)
let nmi = u16::from_le_bytes([rom_data[vector_start], rom_data[vector_start + 1]]);
// RESET vector
let reset = u16::from_le_bytes([rom_data[vector_start + 2], rom_data[vector_start + 3]]);
// IRQ vector
let irq = u16::from_le_bytes([rom_data[vector_start + 4], rom_data[vector_start + 5]]);
// All vectors should be in the $C000-$FFFF range
assert!(nmi >= 0xC000, "NMI vector {nmi:#06X} should be >= $C000");
assert!(
reset >= 0xC000,
"RESET vector {reset:#06X} should be >= $C000"
);
assert!(irq >= 0xC000, "IRQ vector {irq:#06X} should be >= $C000");
// RESET should point to the start of code ($C000)
assert_eq!(reset, 0xC000, "RESET should point to $C000");
}
#[test]
fn link_includes_chr_data() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let rom_data = linker.link(&user_code);
// CHR starts after PRG
let chr_start = 16 + 16384;
// First 16 bytes should be the smiley sprite
assert_ne!(
&rom_data[chr_start..chr_start + 16],
&[0u8; 16],
"CHR data should contain sprite tile"
);
}
#[test]
fn link_rom_size_correct() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let rom_data = linker.link(&user_code);
// 16 header + 16384 PRG + 8192 CHR
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
}
#[test]
fn link_with_sprites_places_chr_data() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let sprite_bytes: Vec<u8> = (0x20..0x30).collect(); // 16 bytes, one tile
let sprites = vec![SpriteData {
name: "Player".into(),
tile_index: 1,
chr_bytes: sprite_bytes.clone(),
}];
let rom_data = linker.link_with_assets(&user_code, &sprites);
// CHR starts right after the 16-byte iNES header and 16 KB PRG bank.
let chr_start = 16 + 16384;
// Tile 0 should still contain the built-in smiley (first 16 bytes, not
// all zero).
let tile0 = &rom_data[chr_start..chr_start + 16];
assert_ne!(
tile0, &[0u8; 16],
"default smiley should occupy tile index 0",
);
// Tile 1 (CHR offset 16) should contain the sprite's CHR bytes exactly.
let tile1 = &rom_data[chr_start + 16..chr_start + 32];
assert_eq!(tile1, sprite_bytes.as_slice());
// Tile 2 and beyond should be untouched (all zeros).
let tile2 = &rom_data[chr_start + 32..chr_start + 48];
assert_eq!(tile2, &[0u8; 16]);
}
#[test]
fn link_with_sprites_spanning_multiple_tiles() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
// 32 bytes = 2 tiles. The linker should place them consecutively
// starting at the requested tile index.
let sprite_bytes: Vec<u8> = (0..32).collect();
let sprites = vec![SpriteData {
name: "Big".into(),
tile_index: 4,
chr_bytes: sprite_bytes.clone(),
}];
let rom_data = linker.link_with_assets(&user_code, &sprites);
let chr_start = 16 + 16384;
// Tile 4 starts at CHR offset 64.
let placed = &rom_data[chr_start + 64..chr_start + 64 + 32];
assert_eq!(placed, sprite_bytes.as_slice());
}
compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling Five language features and optimizations from the planned-work backlog: - **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and the NMI handler gains a `JSR __audio_tick` splice (via the linker's `__audio_used` marker lookup) that ages an SFX countdown counter and mutes pulse 1 when the tone expires. Programs that never trigger audio pay zero ROM cost. - **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`, `Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context tracks variable types via the analyzer's symbol table and routes expressions through the 8-bit or 16-bit path based on operand width. Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the high byte; compares dispatch high-byte-first with a short-circuit low-byte fallback. Fixes a silent miscompile where `big += 1` on a u16 var only incremented the low byte. - **Multi-scanline handlers per state**: `gen_scanline_irq` now dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the MMC3 counter with the delta to the next scanline in the same state. `gen_scanline_reload` resets the step counter at the top of each NMI so a state with multiple handlers fires them in ascending line order. Previously only the first handler per state ever fired. - **IR temp slot recycling**: `build_use_counts` pre-scans each function to count per-temp uses; `retire_op_sources` decrements the counts after each op and pushes dead slots back onto `free_slots` for later allocation. `bitwise_ops.ne` used to crash (debug) or miscompile (release) once it hit 128 concurrent temps; with recycling the same function now uses ~4 slots instead of 136. - **INC/DEC peephole fold + improved dead-load elimination**: `fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into a single `INC addr` (and the SEC/SBC variant into `DEC addr`), saving 5 bytes and 5 cycles per increment. The fold is suppressed when the next instruction reads carry. `remove_dead_loads` now walks past INC/DEC/STX/STY (which don't touch A) to find the actual next A-use, catching more dead loads. Tests: 331 unit + 39 integration (up from 313 + 37), including new guards for audio, u16, multi-scanline, and slot recycling. https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:21:53 +00:00
#[test]
fn link_splices_audio_tick_when_user_marker_present() {
// When user code contains the `__audio_used` marker label (the
// IR codegen emits this whenever it sees a `play`/`start_music`/
// `stop_music` op), the linker must splice a `JSR __audio_tick`
// into the NMI handler prologue AND link in the audio driver
// body so the JSR target exists.
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![
// Pretend user code with the marker the codegen would emit.
Instruction::new(NOP, AM::Label("__audio_used".into())),
Instruction::implied(NOP),
];
let rom_data = linker.link(&user_code);
// The ROM should be valid even with the splice — the driver
// body has to fit in bank 0 without overflowing.
let info = rom::validate_ines(&rom_data).unwrap();
assert_eq!(info.prg_banks, 1);
}
#[test]
fn link_omits_audio_tick_when_no_marker() {
// User code without the marker should not pay any ROM cost
// for the audio driver. We can't easily inspect bytes, but we
// can at least verify the ROM builds and has a normal shape.
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let rom_data = linker.link(&user_code);
let info = rom::validate_ines(&rom_data).unwrap();
assert_eq!(info.prg_banks, 1);
}
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver The audio subsystem was a sketch: `play name` / `start_music name` / `stop_music` parsed, lowered, and emitted a few hardcoded register writes from a builtin name table. No user-declared effects, no per-frame envelope, no note streams, no real engine. This flesh-out brings audio up to the quality bar of the rest of the compiler (sprites, palettes, bank switching, scanline IRQ, etc.) with a full data-driven pipeline: ## Asset pipeline (new `src/assets/audio.rs`) - `sfx Name { duty, pitch, volume }` blocks compile into per-frame pulse-1 envelopes. Pitch/volume arrays must match in length; each entry is one NMI's worth of `$4000` data. - `music Name { duty, volume, repeat, notes }` blocks compile into flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest, 1-60 indexes a builtin period table covering C1-B5. - `resolve_sfx` / `resolve_music` walk the program for `play` / `start_music` references and append builtin fallbacks for any name that isn't user-declared — so `play coin` still works without a `sfx Coin { ... }` block. - Builtin effects (coin, jump, hit, click, cancel, shoot, step) and tracks (theme, battle, victory, gameover) synthesize through the same compile path as user decls — one data model, one driver. ## Runtime engine (`src/runtime/mod.rs`) - `gen_audio_tick()` walks both channels every NMI: reads one envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`, advances ptr, mutes on zero sentinel. Music decrements the note counter, advances to the next `(pitch, dur)` pair on zero, looks up the period through `(__period_table),Y`, loops on `0xFF 0xFF`. - `gen_period_table()` emits a 60-entry equal-tempered table (A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter load bits pre-baked into each high byte. - `gen_data_block()` emits a label + raw-bytes pseudo pair so user sfx/music data can be spliced into PRG with regular labels that the two-pass assembler resolves. - New ZP layout: `$05/$06` music loop base, `$07` music state (duty/volume/loop/active), `$0C-$0F` sfx and music pointers. ## IR codegen (`src/codegen/ir_codegen.rs`) - `with_audio(sfx, music)` registers compile-time trigger constants per blob name. - `gen_play_sfx` emits: write period to `$4002`/`$4003`, load envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of `__sfx_<name>`, mark the sfx counter active. - `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE` with the active bit OR'd in, seeds both ptr and loop base from `__music_<name>`, primes the duration counter. - `gen_stop_music` mutes pulse 2 and clears state. ## Linker (`src/linker/mod.rs`) - New `link_with_all_assets(user_code, sprites, sfx, music)` path that splices driver body, period table, and each sfx/music data blob into PRG — all guarded on the `__audio_used` marker so silent programs pay zero ROM cost. ## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`) - New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim, letting the linker splice ROM data tables into a code section and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve correctly in the same assembly pass. ## Analyzer - `play` / `start_music` now validate the name against user decls and builtin tables. Unknown names emit E0505 with a helpful list of builtins — previously a typo would silently compile to no-op. ## Parser - New `sfx_decl` / `music_decl` grammar with property-style configuration. Strict validation: duty 0-3, volume 0-15, pitch arrays must match volume length, music notes must come in pairs, pitch 0-60, duration ≥ 1. ## Tests +170 new tests across every layer: - `src/assets/audio.rs`: 17 tests (compile, resolve, builtins, shadowing, label sanitation, nested reference walks) - `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music declarations, property validation, play/start_music/stop_music) - `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl acceptance, unknown-name rejection) - `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end, $4000 write, $4004 mute, period table assembly, A4 = 440 Hz, length counter bits, data block verbatim emit) - `src/linker/tests.rs`: 4 tests (sfx/music blob placement, pointer resolution, elision when unused) - `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests to match the new data-driven contract - `tests/integration_test.rs`: 4 end-to-end tests including a user-declared `sfx` + `music` program that verifies bytes land in PRG ROM at the right addresses ## Docs - New Audio section in `docs/language-guide.md` with syntax reference, builtin tables, and an explanation of how the driver works at compile and run time. - `docs/architecture.md` updated to reflect the real audio pipeline instead of the old "audio import stubs" stub. - `docs/future-work.md` moves audio from "status: minimal" to "status: full subsystem" with a narrower list of follow-up work (triangle/noise/DMC channels, NSF/FTM imports, richer envelopes). - `examples/audio_demo.ne` rewritten to showcase user-declared `sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating builtin fallback via `play coin`. Total: 424 tests passing (381 unit + 43 integration), clippy clean, fmt clean, all 19 examples compile. https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
#[test]
fn link_with_audio_data_places_sfx_blobs_in_prg() {
// When user code has the `__audio_used` marker AND we pass in
// sfx data, the linker must:
// 1. Splice in the audio tick (driver body)
// 2. Splice in the period table
// 3. Splice in the envelope blob under its label
// 4. Resolve SymbolLo/SymbolHi references from user code to
// the blob's address (second pass of the assembler)
let linker = Linker::new(Mirroring::Horizontal);
// User code: `LDA #<__sfx_test`, `STA $0C`, `LDA #>__sfx_test`,
// `STA $0D` — simulates what IR codegen's gen_play_sfx emits.
let user_code = vec![
Instruction::new(NOP, AM::Label("__audio_used".into())),
Instruction::new(LDA, AM::SymbolLo("__sfx_test".into())),
Instruction::new(STA, AM::ZeroPage(0x0C)),
Instruction::new(LDA, AM::SymbolHi("__sfx_test".into())),
Instruction::new(STA, AM::ZeroPage(0x0D)),
];
let sfx = vec![SfxData {
name: "test".into(),
period_lo: 0x50,
period_hi: 0x08,
envelope: vec![0xBF, 0xB8, 0xB4, 0xB0, 0x00],
}];
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
let info = rom::validate_ines(&rom).unwrap();
assert_eq!(info.prg_banks, 1);
// The envelope bytes must appear somewhere in PRG. Find them.
let prg = &rom[16..16 + 16384];
let needle = [0xBF, 0xB8, 0xB4, 0xB0, 0x00];
let found = prg.windows(needle.len()).any(|w| w == needle);
assert!(found, "sfx envelope bytes should be spliced into PRG ROM");
}
#[test]
fn link_with_audio_data_places_music_stream_in_prg() {
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![
Instruction::new(NOP, AM::Label("__audio_used".into())),
Instruction::new(LDA, AM::SymbolLo("__music_test".into())),
Instruction::new(STA, AM::ZeroPage(0x0E)),
Instruction::new(LDA, AM::SymbolHi("__music_test".into())),
Instruction::new(STA, AM::ZeroPage(0x0F)),
];
let music = vec![MusicData {
name: "test".into(),
header: 0xA9,
stream: vec![37, 8, 41, 8, 44, 8, 0xFF, 0xFF],
}];
let rom = linker.link_with_all_assets(&user_code, &[], &[], &music);
let prg = &rom[16..16 + 16384];
let needle = [37, 8, 41, 8, 44, 8, 0xFF, 0xFF];
let found = prg.windows(needle.len()).any(|w| w == needle);
assert!(found, "music note stream should be spliced into PRG ROM");
}
#[test]
fn link_with_audio_resolves_sfx_pointer_references() {
// The SymbolLo/SymbolHi references in user code must get
// fixed up to the *actual* PRG address of the envelope blob.
// We can verify this by reading back the user code bytes and
// checking that the LDA immediates point somewhere in the
// valid PRG range ($C000-$FFFF).
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![
Instruction::new(NOP, AM::Label("__audio_used".into())),
Instruction::new(LDA, AM::SymbolLo("__sfx_test".into())),
Instruction::new(LDA, AM::SymbolHi("__sfx_test".into())),
];
let sfx = vec![SfxData {
name: "test".into(),
period_lo: 0x50,
period_hi: 0x08,
envelope: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00],
}];
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
// The user code starts at RESET ($C000) after init+palette_load.
// Rather than compute the exact offset, verify the envelope
// bytes appear at a byte that matches what the LDA immediate
// pair would produce. We find the immediate pair by searching
// for `A9 xx A9 yy` and checking `$xxyy` points at the needle.
let prg = &rom[16..16 + 16384];
let needle = [0xDE, 0xAD, 0xBE, 0xEF, 0x00];
// Find where the envelope lives in ROM.
let env_offset = prg
.windows(needle.len())
.position(|w| w == needle)
.expect("envelope should be in PRG");
let env_addr = 0xC000u16 + env_offset as u16;
// Find any LDA-immediate pair that matches the envelope address.
let lo = (env_addr & 0xFF) as u8;
let hi = (env_addr >> 8) as u8;
let pattern = [0xA9u8, lo, 0xA9, hi];
let matched = prg.windows(pattern.len()).any(|w| w == pattern);
assert!(
matched,
"should find `LDA #<env_addr; LDA #>env_addr` in PRG ($A9 ${lo:02X} $A9 ${hi:02X})"
);
}
#[test]
fn link_without_audio_marker_does_not_emit_period_table() {
// Programs that never use audio must not pay the cost of the
// period table, driver body, or any blobs. We verify this
// indirectly: the `__period_table` label should NOT appear at
// a distinct ROM address from the main body.
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let rom = linker.link_with_all_assets(
&user_code,
&[],
&[SfxData {
name: "unused".into(),
period_lo: 0,
period_hi: 0,
envelope: vec![0xAA, 0xBB, 0x00],
}],
&[],
);
// The envelope bytes `AA BB 00` must NOT appear in PRG — the
// linker should have elided the whole audio section because
// the marker is absent.
let prg = &rom[16..16 + 16384];
let needle = [0xAAu8, 0xBB, 0x00];
let found = prg.windows(needle.len()).any(|w| w == needle);
assert!(
!found,
"unused sfx data should not be spliced when __audio_used is absent"
);
}
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
// ─── Banked linking ────────────────────────────────────────────────
#[test]
fn link_banked_mmc1_produces_multi_bank_rom() {
// MMC1 with two switchable banks should produce a 3-bank ROM
// (2 switchable + 1 fixed). The iNES header must report 3 PRG
// banks, mapper number 1, and the file size must match.
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
let user_code = vec![Instruction::implied(NOP)];
let banks = vec![PrgBank::empty("Level1"), PrgBank::empty("Level2")];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
let info = rom::validate_ines(&rom).unwrap();
assert_eq!(info.prg_banks, 3);
assert_eq!(info.mapper, 1);
assert_eq!(rom.len(), 16 + 3 * 16384 + 8192);
}
#[test]
fn link_banked_uxrom_produces_multi_bank_rom() {
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::UxROM);
let user_code = vec![Instruction::implied(NOP)];
// Four switchable banks = 5 PRG banks total.
let banks = vec![
PrgBank::empty("BankA"),
PrgBank::empty("BankB"),
PrgBank::empty("BankC"),
PrgBank::empty("BankD"),
];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
let info = rom::validate_ines(&rom).unwrap();
assert_eq!(info.prg_banks, 5);
assert_eq!(info.mapper, 2);
}
#[test]
fn link_banked_mmc3_produces_multi_bank_rom() {
let linker = Linker::with_mapper(Mirroring::Vertical, Mapper::MMC3);
let user_code = vec![Instruction::implied(NOP)];
let banks = vec![
PrgBank::empty("Stage1"),
PrgBank::empty("Stage2"),
PrgBank::empty("Stage3"),
];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
let info = rom::validate_ines(&rom).unwrap();
assert_eq!(info.prg_banks, 4);
assert_eq!(info.mapper, 4);
// Vertical mirroring must propagate through the builder.
assert_eq!(info.mirroring, Mirroring::Vertical);
}
#[test]
#[should_panic(expected = "NROM does not support switchable PRG banks")]
fn link_banked_nrom_rejects_switchable_banks() {
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::NROM);
let _ = linker.link_banked(
&[Instruction::implied(NOP)],
&[],
&[],
&[],
&[PrgBank::empty("Nope")],
);
}
#[test]
fn link_banked_fixed_bank_lives_at_end_of_prg() {
// The linker must place the fixed bank *last* so it maps to
// $C000-$FFFF at reset. The vector table at $FFFA..$FFFF must
// land in the final bank. We verify by reading the reset vector
// and checking it points into the fixed bank's address window.
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
let user_code = vec![Instruction::implied(NOP)];
let banks = vec![PrgBank::empty("A"), PrgBank::empty("B")];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// Three PRG banks = 48 KB; the fixed bank is the last 16 KB
// slot in the file, and its $FFFA..$FFFF area holds the
// vector table.
let fixed_bank_offset = 16 + 2 * 16384;
// Vectors live at the last 6 bytes of the fixed bank.
let vec_offset = fixed_bank_offset + 16384 - 6;
let reset = u16::from_le_bytes([rom[vec_offset + 2], rom[vec_offset + 3]]);
assert!(
reset >= 0xC000,
"RESET vector {reset:#06X} should point into fixed bank ($C000-$FFFF)"
);
}
#[test]
fn link_banked_switchable_banks_are_padded_with_ff() {
// Empty switchable banks should end up as 16 KB of $FF — the
// same pad value the ROM builder uses for unset code. This is
// important so banks are always a known shape regardless of
// payload.
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
let user_code = vec![Instruction::implied(NOP)];
let banks = vec![PrgBank::empty("Empty")];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// Bank 0 is at offset 16; check a few bytes are $FF.
assert_eq!(rom[16], 0xFF);
assert_eq!(rom[16 + 100], 0xFF);
// Last byte of bank 0 (just before bank 1 begins).
assert_eq!(rom[16 + 16384 - 1], 0xFF);
}
#[test]
fn link_banked_preserves_switchable_bank_payload_bytes() {
// When a caller provides raw bytes for a switchable bank, the
// linker must splice them in verbatim at the start of that
// bank's slot. This is the hook the compiler uses to ship data
// tables without touching the fixed bank.
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::UxROM);
let user_code = vec![Instruction::implied(NOP)];
let data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42, 0x13];
let banks = vec![PrgBank::with_data("DataBank", data.clone())];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// Bank 0 starts at offset 16. Verify payload lands at the very
// start.
assert_eq!(&rom[16..16 + data.len()], &data[..]);
}
#[test]
fn link_banked_fixed_bank_contains_bank_select_subroutine() {
// The linker must emit `__bank_select` (as labelled 6502 code)
// somewhere in the fixed bank whenever the mapper isn't NROM.
// We verify by assembling a minimal program and searching for
// the opcode signature of the MMC1 bank-select tail — 5 STAs
// to $E000 ($8D $00 $E0).
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
let user_code = vec![Instruction::implied(NOP)];
let banks = vec![PrgBank::empty("Foo")];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// Fixed bank starts at offset 16 + 16384.
let fixed = &rom[16 + 16384..16 + 2 * 16384];
// Find five consecutive STA $E000 (opcode $8D operand $00 $E0)
// instructions with LSR A ($4A) between pairs. This is the
// signature pattern generated by `gen_bank_select(MMC1)`.
let sta_e000 = [0x8D, 0x00, 0xE0];
let lsr_then_sta_e000 = [0x4A, 0x8D, 0x00, 0xE0];
let has_tail = fixed
.windows(lsr_then_sta_e000.len())
.any(|w| w == lsr_then_sta_e000);
let sta_e000_count = fixed
.windows(sta_e000.len())
.filter(|w| w == &sta_e000)
.count();
assert!(
has_tail,
"MMC1 fixed bank should contain LSR A ; STA $E000 pattern"
);
assert!(
sta_e000_count >= 5,
"MMC1 fixed bank should contain >= 5 STA $E000 writes (bank-select + init), got {sta_e000_count}"
);
}
#[test]
fn link_banked_fixed_bank_contains_trampolines_for_declared_banks() {
// When a bank declares an entry label, the linker must emit a
// matching `__tramp_<name>` stub in the fixed bank. We check
// by constructing a bank with an entry label and verifying the
// assembled labels map (via the indirect check: the ROM builds
// without panicking on unresolved labels, which means the
// trampoline's target label — here spliced via a dummy NOP
// label in the fixed bank — resolved).
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
// We splice a fake target label into the user code so the
// trampoline's internal JSR resolves. This simulates the path
// codegen will eventually take (emit the entry label alongside
// the banked user code; the linker resolves it via the banked
// assembler).
let user_code = vec![
Instruction::new(NOP, AM::Label("__fake_bank_entry".into())),
Instruction::implied(NOP),
];
let banks = vec![PrgBank {
name: "Level1".into(),
data: Vec::new(),
entry_label: Some("__fake_bank_entry".into()),
}];
// Should not panic — trampoline and entry label both present.
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
let info = rom::validate_ines(&rom).unwrap();
assert_eq!(info.prg_banks, 2);
}
#[test]
fn link_banked_reset_vector_points_into_fixed_bank_window() {
// The reset vector must land somewhere in $C000-$FFFF — that's
// the CPU address where the fixed bank maps in at boot on every
// supported mapper (NROM, MMC1, UxROM, MMC3).
for mapper in [Mapper::NROM, Mapper::MMC1, Mapper::UxROM, Mapper::MMC3] {
let linker = Linker::with_mapper(Mirroring::Horizontal, mapper);
let user_code = vec![Instruction::implied(NOP)];
let banks: Vec<PrgBank> = if mapper == Mapper::NROM {
Vec::new()
} else {
vec![PrgBank::empty("X")]
};
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// Last 6 bytes of PRG = vectors.
let prg_end = 16 + rom::validate_ines(&rom).unwrap().prg_banks * 16384;
let reset_bytes = [rom[prg_end - 4], rom[prg_end - 3]];
let reset = u16::from_le_bytes(reset_bytes);
assert!(
(0xC000..=0xFFFF).contains(&reset),
"{mapper:?} reset vector {reset:#06X} must live in fixed-bank window"
);
}
}
#[test]
fn link_banked_rom_size_matches_bank_count() {
// For each banked mapper, verify total ROM file size =
// 16 header + N * 16 KB PRG + 8 KB CHR.
for (mapper, switchable) in [
(Mapper::MMC1, 0usize),
(Mapper::MMC1, 1),
(Mapper::MMC1, 3),
(Mapper::UxROM, 0),
(Mapper::UxROM, 7),
(Mapper::MMC3, 0),
(Mapper::MMC3, 15),
] {
let linker = Linker::with_mapper(Mirroring::Horizontal, mapper);
let user_code = vec![Instruction::implied(NOP)];
let banks: Vec<PrgBank> = (0..switchable)
.map(|i| PrgBank::empty(format!("B{i}")))
.collect();
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
let expected_prg_banks = switchable + 1;
let expected_len = 16 + expected_prg_banks * 16384 + 8192;
assert_eq!(
rom.len(),
expected_len,
"{mapper:?} with {switchable} switchable banks: expected {expected_len} bytes, got {}",
rom.len(),
);
}
}
#[test]
fn link_with_mapper_nrom_produces_single_bank_rom() {
// Regression: calling link_banked with NROM and no switchable
// banks should produce the same 1-bank layout as the legacy
// `link_with_all_assets` — no extra cost for the new API.
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::NROM);
let user_code = vec![Instruction::implied(NOP)];
let rom = linker.link_banked(&user_code, &[], &[], &[], &[]);
let info = rom::validate_ines(&rom).unwrap();
assert_eq!(info.prg_banks, 1);
assert_eq!(info.mapper, 0);
assert_eq!(rom.len(), 16 + 16384 + 8192);
}
#[test]
fn link_banked_chr_rom_survives_with_switchable_banks() {
// The default smiley + any sprites should still appear in CHR
// ROM even when switchable PRG banks are present.
let linker = Linker::with_mapper(Mirroring::Horizontal, Mapper::MMC1);
let user_code = vec![Instruction::implied(NOP)];
let banks = vec![PrgBank::empty("X")];
let rom = linker.link_banked(&user_code, &[], &[], &[], &banks);
// CHR starts after 2 PRG banks.
let chr_start = 16 + 2 * 16384;
// First 16 bytes = smiley tile, non-zero.
assert_ne!(&rom[chr_start..chr_start + 16], &[0u8; 16]);
}
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 palette_load_writes_to_ppu() {
let linker = Linker::new(Mirroring::Horizontal);
let palette_insts = linker.gen_palette_load();
// Should write to PPU address register ($2006) twice
let ppu_addr_writes: Vec<_> = palette_insts
.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2006))
.collect();
assert_eq!(
ppu_addr_writes.len(),
2,
"should set PPU address (high and low bytes)"
);
// Should write 32 palette bytes to $2007
let ppu_data_writes: Vec<_> = palette_insts
.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2007))
.collect();
assert_eq!(
ppu_data_writes.len(),
32,
"should write all 32 palette bytes"
);
}