1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

compiler: i16 / SRAM saves / inline-asm dot labels / docs

Another batch from the cc65/nesdoug catalogue. All gated on
parser-level opt-in or default-false attributes so existing
programs produce byte-identical ROMs (no committed .nes file
changed).

**§A — `i16` signed 16-bit type:**
- New `KwI16` lexer token, `NesType::I16` AST variant, parser
  case in `parse_type`. Type-size and integer-type tables
  treat `i16` like `u16` (2 bytes, integer).
- IR lowering accepts `i16` everywhere it accepts `u16` for
  wide-load / wide-store / widen-narrow paths.
- New constant fold for `UnaryOp::Negate(IntLiteral(v))` that
  emits the wide two's-complement form. Without it, `var vy:
  i16 = -10` would zero-extend to `$00F6` (= 246) instead of
  sign-extending to `$FFF6` (= -10). Negative literals now
  store the right bytes.
- Comparisons reuse the existing unsigned 16-bit compare ops
  (matching the existing `i8` behaviour). Documented in the
  `NesType::I16` doc comment and in `future-work.md` §A.
- Example `examples/i16_demo.ne` with committed golden.
- Tests cover the literal-fold sign-extension and end-to-end
  compile of the example.

**§S — SRAM / battery-backed saves:**
- New `save { var ... }` top-level block. Lexer + parser opt
  into a dedicated `KwSave` token. Analyzer allocates save
  vars from a separate `next_sram_addr` bump pointer starting
  at `$6000`, capped at `$8000` (8 KB cartridge SRAM window).
- Linker reads `analysis.has_battery_saves` and flips iNES
  byte-6 bit-1 via the new `RomBuilder::set_battery` /
  `Linker::with_battery` chain.
- New `W0111` warning for save-var initializers — SRAM is
  preserved across power cycles, so an init expression would
  either silently never run or clobber persisted data on
  every boot. The warning teaches the user about the
  magic-byte sentinel pattern.
- Struct fields in save blocks are explicitly rejected for now
  (the field-flattening path uses the main-RAM allocator).
- Example `examples/sram_demo.ne` with committed golden, plus
  4 integration tests.

**§D (partial) — inline-asm `.label:` syntax:**
- Codegen-side mangler rewrites `.IDENT` → `__ilab_<N>_IDENT`
  per inline-asm block, where `<N>` is the call site's
  monotonic suffix. Two `asm { .loop: ... }` blocks in the
  same function now coexist without colliding in the linker's
  label table.
- Bounds checks on `.` placement: `$2002` and `name.field`
  are unaffected; only `.IDENT` in label / branch context
  triggers the rewrite. Two integration tests pin the
  uniqueness and dollar-vs-dot disambiguation.

**§X follow-up — Mesen trace-log docs:**
- New "Debugger-assisted workflows" section in
  `docs/nes-reference.md` walking through the Mesen / FCEUX
  log workflows alongside the new `debug_port:` attribute.

**Misc:**
- `future-work.md` updated to mark the shipped items out of
  the catalogue and reshuffle the priority ranking. Remaining
  niche follow-ups (signedness on Cmp16, struct save fields,
  inline-asm format specifiers) documented inline so future
  passes know the design.

All 757 tests pass. Clippy clean. 46/46 emulator goldens match.
This commit is contained in:
Claude 2026-04-18 20:49:06 +00:00
parent e0b268eea9
commit 7b4570eee5
No known key found for this signature in database
28 changed files with 841 additions and 58 deletions

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

View file

@ -43,7 +43,7 @@ fn compile(source: &str) -> Vec<u8> {
let mut instructions = codegen.generate(&ir_program);
nescript::codegen::peephole::optimize(&mut instructions);
let linker = Linker::new(program.game.mirroring);
let linker = Linker::new(program.game.mirroring).with_battery(analysis.has_battery_saves);
linker.link_with_all_assets(&instructions, &sprites, &sfx, &music)
}
@ -3412,6 +3412,283 @@ start Main
);
}
#[test]
fn inline_asm_dot_labels_are_per_block_unique() {
// Two inline-asm blocks in the same function can both use
// `.loop:` without colliding in the linker's label table.
// The codegen mangles `.X` to `__ilab_<N>_X` where `<N>` is
// the per-call-site monotonic suffix.
let source = r#"
game "DotLabelCollide" { mapper: NROM }
var counter: u8 = 0
fun double_loop() {
asm {
LDX #5
.loop:
DEX
BNE .loop
STX {counter}
}
asm {
LDX #3
.loop:
DEX
BNE .loop
STX {counter}
}
}
on frame { double_loop() }
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.expect("parse should succeed");
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,
&[],
&[],
&[],
);
// Both `.loop:` definitions should be present, mangled to
// distinct `__ilab_<N>_loop` labels.
let mangled: Vec<&String> = linked
.labels
.keys()
.filter(|k| k.starts_with("__ilab_") && k.ends_with("_loop"))
.collect();
assert!(
mangled.len() >= 2,
"expected at least two mangled `.loop` labels, found {}: {:?}",
mangled.len(),
mangled
);
}
#[test]
fn inline_asm_dot_label_does_not_match_dollar_hex() {
// `STA $2002` writes to PPU status; the substituter must NOT
// mangle the `$2002` operand — only `.IDENT` patterns. This is
// a regression check for the dot-vs-dollar disambiguation.
let source = r#"
game "DollarOk" { mapper: NROM }
fun touch_ppu() {
asm {
LDA $2002
}
}
on frame { touch_ppu() }
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
assert!(
analysis.diagnostics.iter().all(|d| !d.is_error()),
"$2002 in inline asm should compile cleanly; got {:?}",
analysis.diagnostics
);
// Compile to a ROM — if the mangler corrupted the operand,
// the asm parser would error and the linker would emit BRK.
let _rom = compile(source);
}
#[test]
fn save_block_allocates_at_sram_window_and_sets_battery_bit() {
// `save { var x: u16 = 0 }` should land at $6000+ (iNES SRAM
// window) and flip iNES header byte-6 bit-1 so emulators
// persist the region across power cycles.
let source = r#"
game "SaveProg" { mapper: NROM }
save {
var hi: u16 = 0
var coins: u8 = 0
}
on frame {
coins += 1
}
start Main
"#;
let (program, _) = nescript::parser::parse(source);
let program = program.expect("parse should succeed");
let analysis = analyzer::analyze(&program);
assert!(
analysis.has_battery_saves,
"save block should set has_battery_saves"
);
let hi_alloc = analysis
.var_allocations
.iter()
.find(|a| a.name == "hi")
.expect("hi should have a VarAllocation");
assert_eq!(hi_alloc.address, 0x6000, "hi should land at $6000");
assert_eq!(hi_alloc.size, 2, "u16 hi should be 2 bytes");
let coins_alloc = analysis
.var_allocations
.iter()
.find(|a| a.name == "coins")
.expect("coins should have a VarAllocation");
assert_eq!(
coins_alloc.address, 0x6002,
"coins should land right after hi"
);
// Build the ROM and verify the iNES header bit.
let rom = compile(source);
assert_eq!(
rom[6] & 0x02,
0x02,
"iNES byte 6 bit 1 should be set for battery"
);
}
#[test]
fn no_save_block_leaves_battery_bit_clear() {
// Sanity: a program with no save block must NOT set the
// battery bit.
let source = r#"
game "NoSave" { mapper: NROM }
on frame { }
start Main
"#;
let rom = compile(source);
assert_eq!(rom[6] & 0x02, 0, "no save block → no battery bit");
}
#[test]
fn save_block_initializer_warns() {
// Initializers on save vars are silently dropped at codegen
// time (globals init at reset, save vars don't). Warn so the
// user knows their default isn't taking effect.
let source = r#"
game "SaveInit" { mapper: NROM }
save {
var hi: u16 = 12345
}
on frame { }
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()
&& matches!(d.code, nescript::errors::ErrorCode::W0111)),
"save-block initializer should emit W0111; got {:?}",
analysis.diagnostics
);
}
#[test]
fn save_block_struct_field_errors() {
// Struct types in save blocks aren't supported yet — must error,
// not silently allocate the struct fields back in main RAM.
let source = r#"
game "BadSave" { mapper: NROM }
struct Stats { hp: u8, mp: u8 }
save {
var stats: Stats
}
on frame { }
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("struct types are not supported")),
"struct in save block should error; got {:?}",
analysis.diagnostics
);
}
#[test]
fn i16_negative_literal_sign_extends_to_wide_store() {
// `var vy: i16 = -10` should store $F6 in the low byte and
// $FF in the high byte — sign-extended two's complement.
// A pre-fix lowering would byte-negate to $F6 and then
// zero-extend, producing $00F6 (= 246) which is wrong for
// signed semantics. Regression assert: scan the assembled
// ROM for `LDA #$F6 / STA <addr> / LDA #$FF / STA <addr+1>`.
let source = r#"
game "I16Neg" { mapper: NROM }
var vy: i16 = -10
on frame {
vy = vy + 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,
&[],
&[],
&[],
);
// `LDA #$F6` = `A9 F6`; `LDA #$FF` = `A9 FF`. Find them
// adjacent to the var's address, accounting for the STA
// instructions in between.
//
// We scan for the byte-pair pattern `A9 F6` followed within
// ~10 bytes by `A9 FF`. That's tight enough to catch the
// neg-literal store sequence and loose enough to survive
// any peephole reordering.
let rom = &linked.rom;
let lda_f6 = rom
.windows(2)
.position(|w| w == [0xA9_u8, 0xF6])
.expect("should emit LDA #$F6 for the i16 negative-literal low byte");
let near = &rom[lda_f6..(lda_f6 + 16).min(rom.len())];
assert!(
near.windows(2).any(|w| w == [0xA9_u8, 0xFF]),
"should emit LDA #$FF (sign-extended high byte) within 16 bytes of LDA #$F6"
);
}
#[test]
fn i16_compiles_with_arithmetic_and_compare() {
// End-to-end compile of an i16 program with `+`, `-`, `>=`,
// and `as u8` cast. Just makes sure the type lowering path
// doesn't panic; the ROM-shape correctness is covered by
// `i16_demo.nes` and its emulator golden.
let source = include_str!("../examples/i16_demo.ne");
let rom = compile(source);
let info = rom::validate_ines(&rom).expect("should be valid iNES");
assert_eq!(info.mapper, 0);
}
#[test]
fn fade_out_emits_jsr_and_forces_palette_bright() {
// `fade_out(n)` should emit a JSR to `__fade_out` and also