1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +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

@ -724,7 +724,9 @@ impl Analyzer {
/// Register a struct declaration. Computes each field's byte
/// offset from the base address (fields are laid out contiguously
/// in declaration order with no padding), and records the total
/// size. v1 structs only support primitive fields (u8/i8/bool).
/// size. Fields may be u8, i8, bool, or u16. Nested structs and
/// array fields are still rejected — the IR-lowering path doesn't
/// model them yet.
fn register_struct(&mut self, s: &StructDecl) {
if self.struct_layouts.contains_key(&s.name) {
self.diagnostics.push(Diagnostic::error(
@ -737,14 +739,17 @@ impl Analyzer {
let mut fields = Vec::new();
let mut offset: u16 = 0;
for field in &s.fields {
// Reject non-primitive field types for now.
// Reject non-primitive field types for now. u16 is
// allowed and takes two bytes; arrays and nested structs
// still error out with a clearer message than before.
let size = match &field.field_type {
NesType::U8 | NesType::I8 | NesType::Bool => 1,
_ => {
NesType::U16 => 2,
NesType::Array(_, _) | NesType::Struct(_) => {
self.diagnostics.push(Diagnostic::error(
ErrorCode::E0201,
format!(
"struct field '{}' has unsupported type '{}' (only u8/i8/bool allowed)",
"struct field '{}' has unsupported type '{}' (struct fields must be u8, i8, u16, or bool)",
field.name, field.field_type
),
field.span,
@ -858,10 +863,18 @@ impl Analyzer {
span: var.span,
},
);
// u16 struct fields occupy two bytes — record that
// explicitly so the IR codegen's global-init pass and
// any size-aware bookkeeping treat the high byte as
// part of the same allocation.
let field_size = match field_type {
NesType::U16 => 2,
_ => 1,
};
self.var_allocations.push(VarAllocation {
name: full_name,
address: address + offset,
size: 1,
size: field_size,
});
}
// Also register the struct variable itself (as a symbol

View file

@ -327,6 +327,87 @@ fn analyze_struct_variable_allocates_fields() {
assert_eq!(py.address, px.address + 1);
}
#[test]
fn analyze_struct_u16_field_allocates_two_bytes() {
// A struct with a u16 field should lay out fields with
// byte-accurate offsets: a u8 followed by a u16 followed by a u8
// puts `b` at offset 1 and `c` at offset 3.
let result = analyze_ok(
r#"
game "Test" { mapper: NROM }
struct Mixed { a: u8, b: u16, c: u8 }
var m: Mixed
on frame {
m.a = 1
m.b = 300
m.c = 7
}
start Main
"#,
);
let a = result
.var_allocations
.iter()
.find(|x| x.name == "m.a")
.expect("m.a should be allocated");
let b = result
.var_allocations
.iter()
.find(|x| x.name == "m.b")
.expect("m.b should be allocated");
let c = result
.var_allocations
.iter()
.find(|x| x.name == "m.c")
.expect("m.c should be allocated");
// Offsets from base: a=0, b=1, c=3 (b is two bytes wide).
assert_eq!(b.address, a.address + 1);
assert_eq!(c.address, a.address + 3);
// u16 field is recorded with size 2 so codegen bookkeeping
// knows how much space the field occupies.
assert_eq!(a.size, 1);
assert_eq!(b.size, 2);
assert_eq!(c.size, 1);
}
#[test]
fn analyze_struct_with_array_field_is_rejected() {
// Array fields are still rejected — the analyzer only accepts
// u8/i8/u16/bool scalar fields in v1 structs.
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
struct Bag { xs: u8[4] }
var b: Bag
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0201),
"array struct field should emit E0201: {errors:?}"
);
}
#[test]
fn analyze_struct_with_nested_struct_field_is_rejected() {
// Nested struct fields are still rejected — only scalar primitives.
let errors = analyze_errors(
r#"
game "Test" { mapper: NROM }
struct Inner { a: u8 }
struct Outer { inner: Inner }
var o: Outer
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::E0201),
"nested struct field should emit E0201: {errors:?}"
);
}
#[test]
fn analyze_struct_unknown_field_errors() {
let errors = analyze_errors(

View file

@ -556,6 +556,7 @@ mod tests {
name: "T".to_string(),
mapper: Mapper::NROM,
mirroring: Mirroring::Horizontal,
header: HeaderFormat::Ines1,
span: Span::dummy(),
},
globals: Vec::new(),

View file

@ -162,7 +162,7 @@ pub fn resolve_backgrounds(program: &Program) -> Vec<BackgroundData> {
mod tests {
use super::*;
use crate::lexer::Span;
use crate::parser::ast::{GameDecl, Mapper, Mirroring, SpriteDecl};
use crate::parser::ast::{GameDecl, HeaderFormat, Mapper, Mirroring, SpriteDecl};
fn make_program(sprite: SpriteDecl) -> Program {
Program {
@ -170,6 +170,7 @@ mod tests {
name: "Test".to_string(),
mapper: Mapper::NROM,
mirroring: Mirroring::Horizontal,
header: HeaderFormat::Ines1,
span: Span::dummy(),
},
globals: Vec::new(),
@ -247,6 +248,7 @@ mod tests {
name: "Test".to_string(),
mapper: Mapper::NROM,
mirroring: Mirroring::Horizontal,
header: HeaderFormat::Ines1,
span: Span::dummy(),
},
globals: Vec::new(),

View file

@ -249,10 +249,18 @@ impl LoweringContext {
let full = format!("{}.{fname}", var.name);
let fvid = self.get_or_create_var(&full);
let fval = self.eval_const(fexpr);
// Look up the field's type from the analyzer's
// symbol table so u16 fields record a size of 2
// and the IR codegen's initializer loop writes
// both bytes.
let field_size = match self.var_types.get(&full) {
Some(NesType::U16) => 2,
_ => 1,
};
self.globals.push(IrGlobal {
var_id: fvid,
name: full,
size: 1,
size: field_size,
init_value: fval,
init_array: Vec::new(),
});
@ -573,6 +581,13 @@ impl LoweringContext {
let field_var = self.get_or_create_var(&full);
let val = self.lower_expr(fexpr);
self.emit(IrOp::StoreVar(field_var, val));
// u16 fields need the high byte written too — the
// `widen` helper yields a zero-extended high temp
// when the RHS is narrow.
if matches!(self.var_types.get(&full), Some(NesType::U16)) {
let (_, val_hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(field_var, val_hi));
}
}
return;
}
@ -670,22 +685,74 @@ impl LoweringContext {
// The analyzer synthesizes a variable named
// `"struct.field"` for each struct field, so we can
// treat field assignment as a regular variable
// assignment to that synthetic name.
// assignment to that synthetic name. u16 fields
// follow the same two-byte path as u16 globals.
let full_name = format!("{name}.{field}");
let var_id = self.get_or_create_var(&full_name);
let dest_is_u16 = matches!(self.var_types.get(&full_name), Some(NesType::U16));
match op {
AssignOp::Assign => {
let val = self.lower_expr(expr);
self.emit(IrOp::StoreVar(var_id, val));
if dest_is_u16 {
// Narrow value: zero-extend via widen
// (which returns the original hi temp if
// the value is already wide).
let (_, val_hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, val_hi));
}
}
_ => {
let current = self.fresh_temp();
self.emit(IrOp::LoadVar(current, var_id));
if dest_is_u16 {
let current_hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(current_hi, var_id));
self.make_wide(current, current_hi);
}
let rhs = self.lower_expr(expr);
let result = self.fresh_temp();
let ir_op = compound_assign_op(op, result, current, rhs, expr, self);
self.emit(ir_op);
self.emit(IrOp::StoreVar(var_id, result));
let wide = dest_is_u16 || self.is_wide(current) || self.is_wide(rhs);
if wide && matches!(op, AssignOp::PlusAssign | AssignOp::MinusAssign) {
let (a_lo, a_hi) = self.widen(current);
let (b_lo, b_hi) = self.widen(rhs);
let d_hi = self.fresh_temp();
match op {
AssignOp::PlusAssign => self.emit(IrOp::Add16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
AssignOp::MinusAssign => self.emit(IrOp::Sub16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
_ => unreachable!(),
}
self.make_wide(result, d_hi);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
self.emit(IrOp::StoreVarHi(var_id, d_hi));
}
} else {
let ir_op = compound_assign_op(op, result, current, rhs, expr, self);
self.emit(ir_op);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
// High byte unchanged by 8-bit op;
// keep the previously-loaded high
// byte.
let (_, cur_hi) = self.widen(current);
self.emit(IrOp::StoreVarHi(var_id, cur_hi));
}
}
}
}
}
@ -927,11 +994,19 @@ impl LoweringContext {
Expr::FieldAccess(name, field, _) => {
// Field access lowers to a plain load of the
// synthetic `"struct.field"` variable produced by the
// analyzer.
// analyzer. u16 fields follow the same two-byte path
// as u16 globals — load the low byte via `LoadVar`
// and the high byte via `LoadVarHi`, then register
// the pair as wide.
let full_name = format!("{name}.{field}");
let var_id = self.get_or_create_var(&full_name);
let t = self.fresh_temp();
self.emit(IrOp::LoadVar(t, var_id));
if matches!(self.var_types.get(&full_name), Some(NesType::U16)) {
let hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(hi, var_id));
self.make_wide(t, hi);
}
t
}
Expr::BinaryOp(left, op, right, _) => self.lower_binop(left, *op, right),

View file

@ -4,7 +4,7 @@ mod tests;
use crate::asm;
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
use crate::assets::{BackgroundData, MusicData, PaletteData, SfxData};
use crate::parser::ast::{Mapper, Mirroring};
use crate::parser::ast::{HeaderFormat, Mapper, Mirroring};
use crate::rom::RomBuilder;
use crate::runtime;
@ -12,6 +12,7 @@ use crate::runtime;
pub struct Linker {
mirroring: Mirroring,
mapper: Mapper,
header_format: HeaderFormat,
}
/// CHR data for a sprite, placed at a specific tile index in CHR ROM.
@ -115,11 +116,25 @@ impl Linker {
Self {
mirroring,
mapper: Mapper::NROM,
header_format: HeaderFormat::Ines1,
}
}
pub fn with_mapper(mirroring: Mirroring, mapper: Mapper) -> Self {
Self { mirroring, mapper }
Self {
mirroring,
mapper,
header_format: HeaderFormat::Ines1,
}
}
/// Opt into the NES 2.0 header format for the emitted ROM.
/// Chainable builder method — returns `self` so callers can
/// write `Linker::with_mapper(m, p).with_header(HeaderFormat::Nes2)`.
#[must_use]
pub fn with_header(mut self, header: HeaderFormat) -> Self {
self.header_format = header;
self
}
/// Link all code sections into a .nes ROM.
@ -454,6 +469,9 @@ impl Linker {
// Build ROM
let mut builder = RomBuilder::new(self.mirroring);
builder.set_mapper(crate::rom::mapper_number(self.mapper));
if self.header_format == HeaderFormat::Nes2 {
builder.enable_nes2();
}
// Multi-bank layout: each switchable bank is an independent
// 16 KB slot whose contents the linker takes verbatim from

View file

@ -349,7 +349,8 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result<Vec<u8>, ()> {
// outgrow 16 KB have real ROM space to grow into and so
// mapper-specific fixtures (vectors, trampolines, bank-select
// helpers) land in the right place.
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper);
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper)
.with_header(program.game.header);
let switchable_banks: Vec<PrgBank> = program
.banks
.iter()

View file

@ -154,6 +154,9 @@ pub struct GameDecl {
pub name: String,
pub mapper: Mapper,
pub mirroring: Mirroring,
/// iNES header flavor to emit. Defaults to [`HeaderFormat::Ines1`];
/// programs can opt into NES 2.0 via `game Foo { header: nes2 }`.
pub header: HeaderFormat,
pub span: Span,
}
@ -171,6 +174,22 @@ pub enum Mirroring {
Vertical,
}
/// iNES header format to emit in the .nes file.
///
/// `Ines1` is the classic 16-byte iNES 1.0 header that every
/// `NEScript` program has used to date. `Nes2` opts into the
/// backwards-compatible NES 2.0 extension: the header is still
/// 16 bytes, but byte 7 bits 2-3 are set to `10` and bytes 8-15
/// carry extended metadata (submapper, PRG/CHR size MSBs, PRG RAM,
/// CHR RAM, timing, etc.). NES 2.0 is strictly additive — any
/// emulator that doesn't understand it falls back to reading the
/// header as iNES 1.0.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeaderFormat {
Ines1,
Nes2,
}
#[derive(Debug, Clone)]
pub struct BankDecl {
pub name: String,

View file

@ -343,6 +343,7 @@ impl Parser {
let mut mapper = Mapper::NROM;
let mut mirroring = Mirroring::Horizontal;
let mut header = HeaderFormat::Ines1;
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
let (key, _) = self.expect_ident()?;
@ -379,6 +380,21 @@ impl Parser {
}
};
}
"header" => {
let (val, _) = self.expect_ident()?;
header = match val.as_str() {
"ines1" | "ines" => HeaderFormat::Ines1,
"nes2" => HeaderFormat::Nes2,
_ => {
return Err(Diagnostic::error(
ErrorCode::E0201,
format!("unknown header format '{val}'"),
self.current_span(),
)
.with_help("supported header formats: ines1, nes2"));
}
};
}
_ => {
return Err(Diagnostic::error(
ErrorCode::E0201,
@ -394,6 +410,7 @@ impl Parser {
name,
mapper,
mirroring,
header,
span: Span::new(
start_span.file_id,
start_span.start,

View file

@ -45,6 +45,38 @@ fn parse_game_with_mirroring() {
assert_eq!(prog.game.mirroring, Mirroring::Vertical);
}
#[test]
fn parse_game_default_header_is_ines1() {
// Programs that don't mention `header:` should default to
// iNES 1.0 — the current behaviour every example relies on.
let prog = parse_ok(MINIMAL_GAME);
assert_eq!(prog.game.header, HeaderFormat::Ines1);
}
#[test]
fn parse_game_with_nes2_header() {
// Opting into NES 2.0 via `header: nes2`.
let src = r#"
game "Test" { mapper: NROM header: nes2 }
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
assert_eq!(prog.game.header, HeaderFormat::Nes2);
}
#[test]
fn parse_game_with_ines1_header_explicit() {
// Explicitly asking for iNES 1.0 (the default) also parses.
let src = r#"
game "Test" { mapper: NROM header: ines1 }
on frame { wait_frame }
start Main
"#;
let prog = parse_ok(src);
assert_eq!(prog.game.header, HeaderFormat::Ines1);
}
// ── Variables ──
#[test]

View file

@ -1,7 +1,7 @@
#[cfg(test)]
mod tests;
use crate::parser::ast::{Mapper, Mirroring};
use crate::parser::ast::{HeaderFormat, Mapper, Mirroring};
/// iNES header magic bytes
const INES_MAGIC: [u8; 4] = [0x4E, 0x45, 0x53, 0x1A]; // "NES\x1A"
@ -20,6 +20,12 @@ const CHR_BANK_SIZE: usize = 8192;
/// call [`RomBuilder::set_prg_banks`] the builder writes each bank
/// back-to-back in the order you provided. The iNES header's PRG
/// bank count always reflects the actual number of 16 KB slots.
///
/// By default the builder emits an iNES 1.0 header. Call
/// [`RomBuilder::enable_nes2`] to opt into the NES 2.0 header format,
/// which is backwards-compatible (byte 7 bits 2-3 are set to `10`)
/// and populates bytes 8-15 per the NES 2.0 spec. The header remains
/// 16 bytes either way.
pub struct RomBuilder {
/// One Vec per 16 KB PRG bank, in physical order. An empty
/// outer Vec means no PRG has been set yet; a single inner Vec
@ -28,6 +34,7 @@ pub struct RomBuilder {
chr_data: Vec<u8>,
mapper: u8,
mirroring: Mirroring,
header_format: HeaderFormat,
}
impl RomBuilder {
@ -37,6 +44,7 @@ impl RomBuilder {
chr_data: Vec::new(),
mapper: 0, // NROM
mirroring,
header_format: HeaderFormat::Ines1,
}
}
@ -45,6 +53,14 @@ impl RomBuilder {
self.mapper = mapper;
}
/// Opt into the NES 2.0 header format. Bytes 7 bits 2-3 are
/// set to `10` to mark the header as NES 2.0, and bytes 8-15
/// are populated with the extended metadata. The header is
/// still exactly 16 bytes.
pub fn enable_nes2(&mut self) {
self.header_format = HeaderFormat::Nes2;
}
/// Set the PRG ROM data as a single bank. Will be padded to fill
/// 16 KB or 32 KB. Equivalent to calling `set_prg_banks` with a
/// one- or two-element Vec depending on whether the data crosses
@ -108,10 +124,11 @@ impl RomBuilder {
let mut rom = Vec::with_capacity(16 + prg_size + chr_size);
// iNES header (16 bytes)
// iNES header (16 bytes — NES 2.0 is the same size, it just
// reinterprets bytes 7-15).
rom.extend_from_slice(&INES_MAGIC);
rom.push(prg_banks as u8); // PRG ROM banks (16 KB units)
rom.push(chr_banks as u8); // CHR ROM banks (8 KB units)
rom.push(prg_banks as u8); // PRG ROM banks (16 KB units) — low 8 bits
rom.push(chr_banks as u8); // CHR ROM banks (8 KB units) — low 8 bits
// Flags 6: mirroring, mapper low nibble
let mut flags6 = match self.mirroring {
@ -121,12 +138,47 @@ impl RomBuilder {
flags6 |= (self.mapper & 0x0F) << 4;
rom.push(flags6);
// Flags 7: mapper high nibble
let flags7 = self.mapper & 0xF0;
// Flags 7: mapper high nibble plus header-format marker.
// NES 2.0 sets bits 2-3 to `10`; iNES 1.0 leaves them at `00`.
let mut flags7 = self.mapper & 0xF0;
if self.header_format == HeaderFormat::Nes2 {
flags7 |= 0b0000_1000;
}
rom.push(flags7);
// Bytes 8-15: padding zeros
rom.extend_from_slice(&[0u8; 8]);
// Bytes 8-15. For iNES 1.0 these are zero-padded. For NES 2.0
// they carry the extended metadata described in the spec:
//
// byte 8 — mapper high nibble (bits 8-11) + submapper (0)
// byte 9 — PRG ROM size MSB (0) | CHR ROM size MSB (0)
// byte 10 — PRG RAM / EEPROM size (0)
// byte 11 — CHR RAM size (0 — we use CHR ROM)
// byte 12 — CPU/PPU timing (0 = NTSC)
// byte 13 — mapper-specific (0)
// byte 14 — miscellaneous ROMs (0)
// byte 15 — default expansion device (0)
//
// Since we never exceed 4 MB of PRG or 2 MB of CHR, don't use
// submappers, and don't have CHR RAM or miscellaneous ROMs,
// most of these stay zero. Only the mapper high nibble in
// byte 8 can be non-zero for mappers numbered >= 256.
match self.header_format {
HeaderFormat::Ines1 => rom.extend_from_slice(&[0u8; 8]),
HeaderFormat::Nes2 => {
// byte 8 low nibble would hold mapper bits 8-11;
// since `self.mapper` is a u8 it's always zero here.
// High nibble would hold the submapper, which we
// never use.
rom.push(0); // byte 8
rom.push(0); // byte 9: PRG/CHR size MSBs
rom.push(0); // byte 10: PRG RAM / EEPROM
rom.push(0); // byte 11: CHR RAM
rom.push(0); // byte 12: NTSC timing
rom.push(0); // byte 13: mapper-specific
rom.push(0); // byte 14: miscellaneous ROMs
rom.push(0); // byte 15: default expansion device
}
}
// PRG ROM data: each bank padded to 16 KB, concatenated.
for mut bank in prg_banks_vec {
@ -145,7 +197,11 @@ impl RomBuilder {
}
}
/// Validate that a byte slice looks like a valid iNES ROM.
/// Validate that a byte slice looks like a valid iNES ROM. Accepts
/// both iNES 1.0 and NES 2.0 headers — the former treats bytes 8-15
/// as zero-padded, the latter as extended metadata. The returned
/// [`RomInfo`] reports which format was detected in `header_format`
/// so callers can distinguish the two.
pub fn validate_ines(data: &[u8]) -> Result<RomInfo, &'static str> {
if data.len() < 16 {
return Err("file too small for iNES header");
@ -168,13 +224,38 @@ pub fn validate_ines(data: &[u8]) -> Result<RomInfo, &'static str> {
Mirroring::Vertical
};
let mapper = (data[6] >> 4) | (data[7] & 0xF0);
// Header format is encoded in byte 7 bits 2-3. `10` (binary)
// means NES 2.0; anything else is iNES 1.0.
let header_format = if data[7] & 0x0C == 0x08 {
HeaderFormat::Nes2
} else {
HeaderFormat::Ines1
};
// iNES 1.0: mapper number is the low nibble of byte 6 plus the
// high nibble of byte 7. NES 2.0 extends this with bits 8-11 in
// byte 8's low nibble — we decode that too, even though we
// never emit mappers >= 256 ourselves.
let mut mapper = (data[6] >> 4) | (data[7] & 0xF0);
if header_format == HeaderFormat::Nes2 {
// Mapper field is 12 bits in NES 2.0; the high nibble in
// byte 8 would push the mapper number past a u8. We still
// only return the low 8 bits here since NEScript never
// emits mappers beyond NROM/MMC1/UxROM/MMC3.
let mapper_high = data[8] & 0x0F;
if mapper_high != 0 {
// Can't fit in a u8 — callers that care about high
// mapper bits should read the header directly.
mapper = mapper.wrapping_add(mapper_high << 4);
}
}
Ok(RomInfo {
prg_banks,
chr_banks,
mapper,
mirroring,
header_format,
})
}
@ -184,6 +265,7 @@ pub struct RomInfo {
pub chr_banks: usize,
pub mapper: u8,
pub mirroring: Mirroring,
pub header_format: HeaderFormat,
}
/// Map a `Mapper` enum variant to its iNES mapper number.

View file

@ -1,5 +1,5 @@
use super::*;
use crate::parser::ast::Mirroring;
use crate::parser::ast::{HeaderFormat, Mirroring};
#[test]
fn build_minimal_rom() {
@ -218,3 +218,100 @@ fn empty_prg_banks_fallback_to_single_bank() {
assert_eq!(rom[4], 1, "default should be 1 PRG bank");
assert_eq!(rom.len(), 16 + 16384);
}
// ─── NES 2.0 header support ────────────────────────────────────────
#[test]
fn ines1_default_has_clear_nes2_marker() {
// Default header format is iNES 1.0 — byte 7 bits 2-3 must be 00.
let builder = RomBuilder::new(Mirroring::Horizontal);
let rom = builder.build();
assert_eq!(
rom[7] & 0x0C,
0x00,
"iNES 1.0 default must not set byte 7 bits 2-3"
);
// Bytes 8-15 must all be zero padding in iNES 1.0.
assert_eq!(&rom[8..16], &[0u8; 8]);
assert_eq!(rom.len(), 16 + 16384);
}
#[test]
fn nes2_header_sets_byte7_bits_2_3() {
// Opting into NES 2.0 must set byte 7 bits 2-3 to `10` (binary).
let mut builder = RomBuilder::new(Mirroring::Horizontal);
builder.enable_nes2();
let rom = builder.build();
assert_eq!(
rom[7] & 0x0C,
0x08,
"NES 2.0 header must set byte 7 bits 2-3 to 10"
);
// Header is still 16 bytes — NES 2.0 is not a longer header,
// it just reinterprets the existing bytes.
assert_eq!(rom.len(), 16 + 16384);
}
#[test]
fn nes2_header_populates_bytes_8_through_15() {
// Bytes 8-15 should all be zero for our tiny ROMs — we don't
// use submappers, oversized PRG/CHR, CHR RAM, or non-NTSC
// timing — but they must still be present (not omitted).
let mut builder = RomBuilder::new(Mirroring::Horizontal);
builder.enable_nes2();
let rom = builder.build();
assert_eq!(&rom[8..16], &[0u8; 8]);
}
#[test]
fn nes2_preserves_mapper_and_mirroring() {
// Opting into NES 2.0 should not disturb the mapper or
// mirroring fields in bytes 6-7.
let mut builder = RomBuilder::new(Mirroring::Vertical);
builder.set_mapper(crate::rom::mapper_number(crate::parser::ast::Mapper::MMC3));
builder.enable_nes2();
let rom = builder.build();
// Vertical mirroring keeps bit 0 of byte 6 set.
assert_eq!(rom[6] & 1, 1);
// Mapper 4 splits 0x40 across byte 6 high nibble and byte 7
// high nibble: 4 = 0b0100 → nibble 0 goes to byte 7.
let info = validate_ines(&rom).unwrap();
assert_eq!(info.mapper, 4);
assert_eq!(info.header_format, HeaderFormat::Nes2);
assert_eq!(info.mirroring, Mirroring::Vertical);
}
#[test]
fn validate_accepts_both_header_formats() {
// iNES 1.0 ROM validates and is marked as `Ines1`.
let ines1 = RomBuilder::new(Mirroring::Horizontal).build();
let info1 = validate_ines(&ines1).unwrap();
assert_eq!(info1.header_format, HeaderFormat::Ines1);
// NES 2.0 ROM validates and is marked as `Nes2`.
let mut b = RomBuilder::new(Mirroring::Horizontal);
b.enable_nes2();
let nes2 = b.build();
let info2 = validate_ines(&nes2).unwrap();
assert_eq!(info2.header_format, HeaderFormat::Nes2);
}
#[test]
fn nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers() {
// NEScript only supports u8 mapper numbers, so byte 8's low
// nibble (mapper bits 8-11) is always zero. Verify that
// explicitly so a future change that accidentally shifts bits
// into byte 8 is caught.
for mapper in [
crate::parser::ast::Mapper::NROM,
crate::parser::ast::Mapper::MMC1,
crate::parser::ast::Mapper::UxROM,
crate::parser::ast::Mapper::MMC3,
] {
let mut builder = RomBuilder::new(Mirroring::Horizontal);
builder.set_mapper(crate::rom::mapper_number(mapper));
builder.enable_nes2();
let rom = builder.build();
assert_eq!(rom[8], 0, "byte 8 should be zero for {mapper:?}");
}
}

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#"