From 33351f8b32274f085e57f9b69d56aedeb628b5eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 02:05:51 +0000 Subject: [PATCH] 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 --- src/analyzer/mod.rs | 23 +++++-- src/analyzer/tests.rs | 81 +++++++++++++++++++++++++ src/assets/audio.rs | 1 + src/assets/resolve.rs | 4 +- src/ir/lowering.rs | 87 ++++++++++++++++++++++++-- src/linker/mod.rs | 22 ++++++- src/main.rs | 3 +- src/parser/ast.rs | 19 ++++++ src/parser/mod.rs | 17 ++++++ src/parser/tests.rs | 32 ++++++++++ src/rom/mod.rs | 102 ++++++++++++++++++++++++++++--- src/rom/tests.rs | 99 +++++++++++++++++++++++++++++- tests/integration_test.rs | 124 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 588 insertions(+), 26 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 4328c48..83a070a 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -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 diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 5b9fb7c..cfe3def 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -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( diff --git a/src/assets/audio.rs b/src/assets/audio.rs index 5fda181..0b80903 100644 --- a/src/assets/audio.rs +++ b/src/assets/audio.rs @@ -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(), diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index 89ca5d0..a74d4e5 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -162,7 +162,7 @@ pub fn resolve_backgrounds(program: &Program) -> Vec { 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(), diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 5814e90..47a5abb 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -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), diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 5fd0751..aa6a0a7 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -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 diff --git a/src/main.rs b/src/main.rs index b50d6f3..c7de34e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -349,7 +349,8 @@ fn compile(input: &PathBuf, opts: &CompileOptions) -> Result, ()> { // 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 = program .banks .iter() diff --git a/src/parser/ast.rs b/src/parser/ast.rs index f5e4e9c..a9d5567 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -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, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5c7da13..76fedbc 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -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, diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 9181f2d..b8bc75d 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -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] diff --git a/src/rom/mod.rs b/src/rom/mod.rs index 01dc8c7..f8d5302 100644 --- a/src/rom/mod.rs +++ b/src/rom/mod.rs @@ -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, 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 { if data.len() < 16 { return Err("file too small for iNES header"); @@ -168,13 +224,38 @@ pub fn validate_ines(data: &[u8]) -> Result { 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. diff --git a/src/rom/tests.rs b/src/rom/tests.rs index 495c5d0..34f2593 100644 --- a/src/rom/tests.rs +++ b/src/rom/tests.rs @@ -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:?}"); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 95a3c33..3d546e6 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 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 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#"