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:
parent
65a63f9a68
commit
33351f8b32
13 changed files with 588 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
102
src/rom/mod.rs
102
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<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.
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue