mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
compiler: PRNG / edge input / palette fade / AxROM / CNROM / FCEUX labels
Closes seven of the cc65/nesdoug parity gaps catalogued in docs/future-work.md in a single pass. All of the new features are gated on marker labels so programs that don't use them produce byte-identical ROM output (every pre-existing committed .nes file round-trips unchanged). Language / runtime additions: - `rand8()` / `rand16()` / `seed_rand(u16)` intrinsics backed by a 16-bit Galois LFSR (~30 bytes of runtime, ~40 cycles per draw). Reset path seeds state to 0xACE1 so the first draw is useful even without explicit seeding. - `p1.button.a.pressed` / `.released` edge-triggered input via a new ReadInputEdge IR op plus an NMI-side prev-frame snapshot into $07E6/$07E7, gated on the `__edge_input_used` marker. - `set_palette_brightness(level)` builtin mapping levels 0..8 to PPU mask emphasis bytes (`$2001`) for neslib-style screen fades. - `mapper: AxROM` (iNES 7) with automatic 32 KB PRG padding so emulators that enforce mapper-7's 32 KB page size boot cleanly. - `mapper: CNROM` (iNES 3) with a reset-time CHR bank 0 select. - `--fceux-labels <prefix>` CLI flag emitting per-bank `.nl` label files and a `.ram.nl` file for FCEUX's debugger. Tests + examples: - Five new example programs with committed .nes ROMs and pixel+audio goldens: prng_demo, edge_input_demo, palette_brightness_demo, axrom_simple, cnrom_simple. - Seven integration tests covering JSR emission, the omitted-when-unused invariant, the NMI prev-input snapshot, the correct mapper numbers for AxROM/CNROM, and negative tests for unknown button names and bad rand8 arity. - `is_intrinsic()` now runs in expression-position Call paths too, so `var x = rand8(1, 2)` errors at compile time instead of silently dropping the extra arguments.
This commit is contained in:
parent
c96135fd86
commit
7507459787
35 changed files with 1272 additions and 22 deletions
1
tests/emulator/goldens/axrom_simple.audio.hash
Normal file
1
tests/emulator/goldens/axrom_simple.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/axrom_simple.png
Normal file
BIN
tests/emulator/goldens/axrom_simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
1
tests/emulator/goldens/cnrom_simple.audio.hash
Normal file
1
tests/emulator/goldens/cnrom_simple.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/cnrom_simple.png
Normal file
BIN
tests/emulator/goldens/cnrom_simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
1
tests/emulator/goldens/edge_input_demo.audio.hash
Normal file
1
tests/emulator/goldens/edge_input_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/edge_input_demo.png
Normal file
BIN
tests/emulator/goldens/edge_input_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 939 B |
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/palette_brightness_demo.png
Normal file
BIN
tests/emulator/goldens/palette_brightness_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
tests/emulator/goldens/prng_demo.audio.hash
Normal file
1
tests/emulator/goldens/prng_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/prng_demo.png
Normal file
BIN
tests/emulator/goldens/prng_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -2970,3 +2970,271 @@ fn debug_build_emits_bounds_check_halt_routine() {
|
|||
"debug halt label is internal; should not leak into .mlb"
|
||||
);
|
||||
}
|
||||
|
||||
// ── PRNG / edge input / palette brightness / new mappers ──
|
||||
|
||||
/// Locate the four-byte sequence `JSR label` within a ROM's assembled
|
||||
/// fixed bank. The search walks the bytes rather than trusting the
|
||||
/// linker label table so a miscompile that drops the JSR fails this
|
||||
/// test even if the label is still resolved.
|
||||
fn contains_jsr_to(rom: &[u8], target_cpu_addr: u16) -> bool {
|
||||
let bytes = target_cpu_addr.to_le_bytes();
|
||||
rom.windows(3)
|
||||
.any(|w| w[0] == 0x20 && w[1] == bytes[0] && w[2] == bytes[1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rand8_lowers_to_jsr_to_rand8() {
|
||||
let source = r#"
|
||||
game "RandDemo" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
on frame {
|
||||
x = rand8()
|
||||
}
|
||||
start Main
|
||||
"#;
|
||||
let rom = compile(source);
|
||||
// The `__rand8` runtime routine is now linked into the ROM.
|
||||
// Its label lands inside the fixed PRG bank at $C000-$FFFF.
|
||||
// Build a fresh linker-level compile so we can scan the label
|
||||
// table too, but the byte-level test is the authoritative one.
|
||||
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
||||
assert_eq!(info.mapper, 0);
|
||||
// Rebuild with the linker to read the label table and verify
|
||||
// our byte scan hits the right JSR.
|
||||
let (program, _) = nescript::parser::parse(source);
|
||||
let program = program.unwrap();
|
||||
let analysis = analyzer::analyze(&program);
|
||||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
optimizer::optimize(&mut ir_program);
|
||||
let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap();
|
||||
let sfx = assets::resolve_sfx(&program).unwrap();
|
||||
let music = assets::resolve_music(&program).unwrap();
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
.with_sprites(&sprites)
|
||||
.with_audio(&sfx, &music);
|
||||
let mut instructions = codegen.generate(&ir_program);
|
||||
nescript::codegen::peephole::optimize(&mut instructions);
|
||||
let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed(
|
||||
&instructions,
|
||||
&sprites,
|
||||
&sfx,
|
||||
&music,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let rand8_addr = *linked
|
||||
.labels
|
||||
.get("__rand8")
|
||||
.expect("__rand_used marker should cause __rand8 to be linked in");
|
||||
assert!(
|
||||
contains_jsr_to(&linked.rom, rand8_addr),
|
||||
"rand8() should emit JSR __rand8 at its call site"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rand_routines_omitted_without_use() {
|
||||
// Sanity: a program that never calls rand8/rand16/seed_rand
|
||||
// should not pay any bytes for the PRNG.
|
||||
let source = r#"
|
||||
game "NoRand" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
on frame { x = x + 1 }
|
||||
start Main
|
||||
"#;
|
||||
let (program, _) = nescript::parser::parse(source);
|
||||
let program = program.unwrap();
|
||||
let analysis = analyzer::analyze(&program);
|
||||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
optimizer::optimize(&mut ir_program);
|
||||
let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap();
|
||||
let sfx = assets::resolve_sfx(&program).unwrap();
|
||||
let music = assets::resolve_music(&program).unwrap();
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
.with_sprites(&sprites)
|
||||
.with_audio(&sfx, &music);
|
||||
let mut instructions = codegen.generate(&ir_program);
|
||||
nescript::codegen::peephole::optimize(&mut instructions);
|
||||
let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed(
|
||||
&instructions,
|
||||
&sprites,
|
||||
&sfx,
|
||||
&music,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
!linked.labels.contains_key("__rand8"),
|
||||
"PRNG routine should not be linked in when unused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_palette_brightness_lowers_to_jsr() {
|
||||
let source = r#"
|
||||
game "FadeDemo" { mapper: NROM }
|
||||
on frame {
|
||||
set_palette_brightness(4)
|
||||
}
|
||||
start Main
|
||||
"#;
|
||||
let (program, _) = nescript::parser::parse(source);
|
||||
let program = program.unwrap();
|
||||
let analysis = analyzer::analyze(&program);
|
||||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
optimizer::optimize(&mut ir_program);
|
||||
let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap();
|
||||
let sfx = assets::resolve_sfx(&program).unwrap();
|
||||
let music = assets::resolve_music(&program).unwrap();
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
.with_sprites(&sprites)
|
||||
.with_audio(&sfx, &music);
|
||||
let mut instructions = codegen.generate(&ir_program);
|
||||
nescript::codegen::peephole::optimize(&mut instructions);
|
||||
let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed(
|
||||
&instructions,
|
||||
&sprites,
|
||||
&sfx,
|
||||
&music,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let addr = *linked
|
||||
.labels
|
||||
.get("__set_palette_brightness")
|
||||
.expect("set_palette_brightness should link __set_palette_brightness");
|
||||
assert!(
|
||||
contains_jsr_to(&linked.rom, addr),
|
||||
"set_palette_brightness should emit JSR __set_palette_brightness"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_input_pressed_emits_prev_snapshot() {
|
||||
// A program that reads `p1.button.a.pressed` should have the
|
||||
// runtime snapshot the previous-frame input byte into
|
||||
// `$07E6` inside the NMI. We verify the store sequence lands.
|
||||
let source = r#"
|
||||
game "EdgeDemo" { mapper: NROM }
|
||||
var hit: u8 = 0
|
||||
on frame {
|
||||
if p1.button.a.pressed {
|
||||
hit += 1
|
||||
}
|
||||
}
|
||||
start Main
|
||||
"#;
|
||||
let (program, _) = nescript::parser::parse(source);
|
||||
let program = program.unwrap();
|
||||
let analysis = analyzer::analyze(&program);
|
||||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
optimizer::optimize(&mut ir_program);
|
||||
let sprites = assets::resolve_sprites(&program, Path::new(".")).unwrap();
|
||||
let sfx = assets::resolve_sfx(&program).unwrap();
|
||||
let music = assets::resolve_music(&program).unwrap();
|
||||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program)
|
||||
.with_sprites(&sprites)
|
||||
.with_audio(&sfx, &music);
|
||||
let mut instructions = codegen.generate(&ir_program);
|
||||
nescript::codegen::peephole::optimize(&mut instructions);
|
||||
let linked = Linker::new(program.game.mirroring).link_banked_with_ppu_detailed(
|
||||
&instructions,
|
||||
&sprites,
|
||||
&sfx,
|
||||
&music,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
// The NMI handler should STA `$07E6` (PREV_INPUT_P1). Scan
|
||||
// the ROM bytes for the `STA abs $07E6` opcode sequence
|
||||
// (0x8D 0xE6 0x07).
|
||||
assert!(
|
||||
linked
|
||||
.rom
|
||||
.windows(3)
|
||||
.any(|w| w[0] == 0x8D && w[1] == 0xE6 && w[2] == 0x07),
|
||||
"edge input should cause the NMI to save the previous-frame P1 byte to $07E6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axrom_rom_has_correct_header_and_size() {
|
||||
let source = r#"
|
||||
game "AxDemo" { mapper: AxROM }
|
||||
var x: u8 = 0
|
||||
on frame { x += 1 }
|
||||
start Main
|
||||
"#;
|
||||
let rom = compile_with_mapper(source);
|
||||
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
||||
assert_eq!(info.mapper, 7, "AxROM is iNES mapper 7");
|
||||
// Two PRG banks = 32 KB = mapper 7's page size.
|
||||
assert_eq!(info.prg_banks, 2, "AxROM should be 32 KB PRG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cnrom_rom_has_correct_header() {
|
||||
let source = r#"
|
||||
game "CnDemo" { mapper: CNROM }
|
||||
var x: u8 = 0
|
||||
on frame { x += 1 }
|
||||
start Main
|
||||
"#;
|
||||
let rom = compile_with_mapper(source);
|
||||
let info = rom::validate_ines(&rom).expect("should be valid iNES");
|
||||
assert_eq!(info.mapper, 3, "CNROM is iNES mapper 3");
|
||||
assert_eq!(info.prg_banks, 1, "CNROM demo uses a single 16 KB PRG bank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_button_edge_emits_diagnostic() {
|
||||
// The analyzer should reject `p1.button.foo.pressed` with an
|
||||
// error — silently compiling to "always false" would make
|
||||
// typos invisible at runtime.
|
||||
let source = r#"
|
||||
game "BadEdge" { mapper: NROM }
|
||||
on frame {
|
||||
if p1.button.asdf.pressed { }
|
||||
}
|
||||
start Main
|
||||
"#;
|
||||
let (program, _) = nescript::parser::parse(source);
|
||||
let program = program.expect("parse should succeed");
|
||||
let analysis = analyzer::analyze(&program);
|
||||
assert!(
|
||||
analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.is_error() && d.message.contains("unknown button")),
|
||||
"unknown button name inside edge-trigger should error; got {:?}",
|
||||
analysis.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rand8_no_args_mismatch_errors() {
|
||||
let source = r#"
|
||||
game "BadRand" { mapper: NROM }
|
||||
on frame {
|
||||
var _x: u8 = rand8(1, 2)
|
||||
}
|
||||
start Main
|
||||
"#;
|
||||
let (program, diags) = nescript::parser::parse(source);
|
||||
// Parser accepts; analyzer should reject.
|
||||
assert!(diags.iter().all(|d| !d.is_error()));
|
||||
let program = program.expect("parse should succeed");
|
||||
let analysis = analyzer::analyze(&program);
|
||||
assert!(
|
||||
analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(nescript::errors::Diagnostic::is_error),
|
||||
"rand8 with arguments should error"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue