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

lang: NES 2.0 headers and u16 struct fields

Implements two items from docs/future-work.md's language-feature gaps:

NES 2.0 header support: `RomBuilder` gains a `header_format` field
and a matching `enable_nes2()` method. When enabled, byte 7 bits 2-3
are set to `10` and bytes 8-15 are populated per the NES 2.0 spec
(submapper, PRG/CHR MSBs, PRG/CHR RAM, timing). The header stays
16 bytes. Programs opt in via `game Foo { header: nes2 }`; the
default remains iNES 1.0 so every committed example ROM is byte
identical. `validate_ines` now detects and reports which format it
parsed.

u16 struct fields: the analyzer's `register_struct` accepts `u16`
fields with a two-byte size and the struct-variable allocator tracks
per-field sizes so the synthesized `pos.x`/`pos.y` globals get the
right address span. IR lowering's `LValue::Field` and
`Expr::FieldAccess` follow the same wide path as u16 globals, and
struct-literal initialization writes both bytes for u16 fields.
Array and nested-struct fields stay rejected with a clearer
message. Existing u8/i8/bool struct programs are unaffected.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
This commit is contained in:
Claude 2026-04-14 02:05:51 +00:00
parent 65a63f9a68
commit 33351f8b32
No known key found for this signature in database
13 changed files with 588 additions and 26 deletions

View file

@ -309,6 +309,130 @@ fn program_with_structs() {
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn program_with_u16_struct_field() {
// Exercise the u16 struct field path end-to-end: declare a
// struct with a mix of u8 and u16 fields, read from and write
// to the u16 field (including a literal > 255), and verify the
// ROM assembles cleanly. The analyzer's field-offset math and
// the IR lowering's wide load/store path both need to agree
// for this to compile at all.
let source = r#"
game "U16Struct" { mapper: NROM }
struct Entity { kind: u8, position: u16, flags: u8 }
var e: Entity
on frame {
e.kind = 1
e.position = 1234
e.flags = 7
if e.position > 1000 {
e.position += 1
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn u16_struct_field_initializer_writes_both_bytes_to_rom() {
// Struct literal initializer with a u16 field > 255 — the
// compiler runs the global-init path at reset time, which
// lowers to two independent LDA/STA pairs (low byte then high
// byte). Unlike per-frame stores, initializers aren't subject
// to the optimizer's dead-store pass, so they're a stable
// place to witness both halves of the u16 write. 1234 = $04D2.
let source = r#"
game "U16Init" { mapper: NROM }
struct Point { tag: u8, x: u16 }
var p: Point = Point { tag: 1, x: 1234 }
on frame {
if p.x > 1000 {
scroll(p.tag, 0)
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
// PRG ROM starts at offset 16 and is 16384 bytes long.
let prg = &rom_data[16..16 + 16384];
// Look for `LDA #$D2 ; STA abs|zp` — opcode $A9 $D2 $85/$8D.
// This is the low-byte initializer for `p.x`.
let mut found_low_store = false;
for i in 0..prg.len().saturating_sub(4) {
if prg[i] == 0xA9 && prg[i + 1] == 0xD2 && (prg[i + 2] == 0x85 || prg[i + 2] == 0x8D) {
found_low_store = true;
break;
}
}
assert!(
found_low_store,
"expected an LDA #$D2 / STA <addr> pair in PRG for the u16 initializer low byte"
);
// And the high byte: `LDA #$04 ; STA abs|zp`.
let mut found_high_store = false;
for i in 0..prg.len().saturating_sub(4) {
if prg[i] == 0xA9 && prg[i + 1] == 0x04 && (prg[i + 2] == 0x85 || prg[i + 2] == 0x8D) {
found_high_store = true;
break;
}
}
assert!(
found_high_store,
"expected an LDA #$04 / STA <addr> pair in PRG for the u16 initializer high byte"
);
}
#[test]
fn u16_struct_field_comparison_emits_wide_compare() {
// Reading a u16 struct field into a comparison should take
// the wide (16-bit) compare path, which produces a distinctive
// two-stage CMP sequence: high byte first (with equal-branch),
// then low byte. Without the u16 lowering, the field would
// be treated as u8 and the comparison would fold to a single
// 8-bit CMP. We detect the wide path by checking that both
// the low byte of 1000 ($E8) and the high byte ($03) appear
// as immediate operands in the emitted PRG — the compiler
// only emits both when it's generating a 16-bit compare.
let source = r#"
game "U16Cmp" { mapper: NROM }
struct Counter { n: u16 }
var c: Counter = Counter { n: 2000 }
on frame {
if c.n > 1000 {
scroll(1, 0)
} else {
scroll(2, 0)
}
}
start Main
"#;
let rom_data = compile(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
let prg = &rom_data[16..16 + 16384];
// 1000 = $03E8. Look for CMP #$03 (A9 03, C9 03) — the high
// byte of the comparison literal. We expect `CMP #$03` ($C9
// $03) to appear somewhere in the CMP-with-constant sequence.
let mut found_high_cmp = false;
for i in 0..prg.len().saturating_sub(2) {
if prg[i] == 0xC9 && prg[i + 1] == 0x03 {
found_high_cmp = true;
break;
}
}
assert!(
found_high_cmp,
"expected a CMP #$03 (16-bit compare high byte) in PRG"
);
}
#[test]
fn program_with_enums() {
let source = r#"